diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..8858358 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,31 @@ +name: macOS + +on: + push: + branches: [main, macos-port] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run build:mac + - uses: actions/upload-artifact@v4 + with: + name: edge-drop-macos-arm64 + path: | + dist/*.dmg + dist/*.zip + if-no-files-found: error diff --git a/.gitignore b/.gitignore index a465b03..825c461 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules out dist +resources/macos/build +resources/macos/bin *.log drag_debug.txt clipboard_debug.log @@ -21,4 +23,3 @@ icons-showcase.svg scripts/generate_showcase.mjs electron-clipboard-search-implementation/ scratch/verify-*.cjs - diff --git a/MACOS.md b/MACOS.md new file mode 100644 index 0000000..7064d52 --- /dev/null +++ b/MACOS.md @@ -0,0 +1,46 @@ +# Edge-Drop macOS port + +This branch keeps the Electron/React interface and adds native macOS adapters for the OS-specific behavior. + +## Run from source + +Requirements: + +- macOS 12 or newer +- Node.js 22 +- Xcode Command Line Tools (`xcode-select --install`) + +```bash +cd /path/to/Edge-Drop +npm install +npm run dev:mac +``` + +The app runs as a menu-bar utility and does not appear in the Dock. Hover at the configured left or right screen edge, press the global shortcut, or use the menu-bar icon. + +## Permissions + +Clipboard history, file drag-in/out, and edge hover require no special macOS permission. The first click-to-paste action asks for **Accessibility** permission because macOS protects synthesized Command+V keyboard events. Grant it under **System Settings → Privacy & Security → Accessibility**. Copy-only actions work without it. + +Edge-Drop respects common macOS concealed/sensitive pasteboard types used by password managers. + +## Build an installable image + +```bash +npm run typecheck +npm test +npm run build:mac +``` + +Artifacts are written to `dist/` as an ARM64 DMG and ZIP. This local build is unsigned. Public distribution should use `npm run build:mac:release` with an Apple Developer ID certificate and notarization credentials configured for electron-builder. + +## Native macOS implementation + +- `resources/macos/EdgeDropMacHelper.swift` reads and writes Finder file URLs through `NSPasteboard`, writes image-plus-file payloads atomically, detects full-screen foreground windows, and sends Command+V through Core Graphics. +- The panel is an accessory/menu-bar app, hidden from Mission Control, and visible across Spaces and full-screen Spaces. +- Launch at login uses Electron's macOS login-item API rather than Windows registry keys. +- The menu-bar icon is a macOS template image and adapts automatically to light/dark menu bars. + +## Current distribution note + +Automatic updating is disabled on macOS until this fork has a signed and notarized release feed. Windows behavior remains unchanged. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..9dbdf02 --- /dev/null +++ b/NOTICE @@ -0,0 +1,14 @@ +Edge-Drop macOS port +Copyright 2026 the macOS fork contributors + +This product is derived from Edge-Drop by Deepender25: +https://github.com/Deepender25/Edge-Drop + +The upstream project and this derivative are licensed under the Apache +License, Version 2.0. See LICENSE. Files changed on the macos-port branch +include platform adapters, packaging, tests, documentation, and build +configuration for macOS. + +Third-party artwork: +Twemoji graphics copyright Twitter, Inc. and other contributors, licensed +under CC-BY 4.0: https://github.com/twitter/twemoji diff --git a/README.md b/README.md index 12fe3e3..b3ea607 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Edge-Drop Logo

+> **macOS port:** the `macos-port` branch adds a native macOS pasteboard helper, menu-bar/Spaces behavior, Finder file copy/paste, Command+V automation, full-screen detection, launch at login, and DMG/ZIP packaging. See [MACOS.md](MACOS.md) for setup and current distribution notes. +

Edge-Drop

@@ -37,7 +39,7 @@ Release Tests License - Platform + Platform

diff --git a/electron/clipboard/formats.ts b/electron/clipboard/formats.ts index 1e101c4..517206b 100644 --- a/electron/clipboard/formats.ts +++ b/electron/clipboard/formats.ts @@ -36,12 +36,25 @@ export function getClipboardSequenceNumber(): number { import { getSystemPowerShellPath, getWritableCwd } from '../main/powershell' import { filterValidPaths } from '../main/pathValidation' import { isStoreBuild } from '../main/config' +import { readMacClipboardFiles } from '../main/macos' const execFileAsync = promisify(execFile) /** Windows clipboard format name for a copied-file list. */ export const CF_FILE_LIST = 'FileNameW' +/** + * Electron normalizes Finder's native `public.file-url` pasteboard type to + * `text/uri-list` on macOS. Keep the native spellings too for compatibility + * with other producers and older Electron releases. + */ +export function isMacFileListFormat(format: string): boolean { + const lower = format.toLowerCase() + return lower === 'text/uri-list' || + lower === 'public.file-url' || + lower === 'nsfilenamespboardtype' +} + /** * Async version: reads the full list of copied file paths via PowerShell * GetFileDropList(), which is the only reliable way to retrieve ALL selected @@ -53,6 +66,14 @@ export const CF_FILE_LIST = 'FileNameW' */ async function readFileListAsync(): Promise { try { + const advertisedFormats = clipboard.availableFormats().map((format) => format.toLowerCase()) + const hasMacFileUrls = advertisedFormats.some(isMacFileListFormat) + if (process.platform === 'darwin' && hasMacFileUrls) { + const paths = await readMacClipboardFiles() + const valid = filterValidPaths(paths ?? []) + return valid.length ? valid : null + } + // First, confirm there is actually a file list on the clipboard before // spawning a process. FileNameW being present is sufficient signal. const buf = clipboard.readBuffer(CF_FILE_LIST) @@ -97,6 +118,24 @@ async function readFileListAsync(): Promise { /** Fast, non-blocking check of FileNameW contents for clipboard signatures. */ function readFileListFast(): string[] | null { try { + if (process.platform === 'darwin') { + for (const format of ['text/uri-list', 'public.file-url', 'NSFilenamesPboardType']) { + const buf = clipboard.readBuffer(format) + if (!buf || buf.length === 0) continue + const raw = buf.toString('utf8').replace(/\0/g, '') + const urls = raw.match(/file:\/\/[^\s<>'"]+/g) ?? [] + const paths = filterValidPaths(urls.flatMap((url) => { + try { + return [decodeURIComponent(new URL(url).pathname)] + } catch { + return [] + } + })) + if (paths.length) return paths + } + return null + } + const buf = clipboard.readBuffer(CF_FILE_LIST) if (!buf || buf.length < 4) return null const wide = buf.toString('utf16le') @@ -116,6 +155,9 @@ function readFileListFast(): string[] | null { */ export function clipboardHasFileNameW(): boolean { try { + if (process.platform === 'darwin') { + return clipboard.availableFormats().some(isMacFileListFormat) + } const buf = clipboard.readBuffer(CF_FILE_LIST) return !!(buf && buf.length >= 4) } catch { @@ -137,6 +179,10 @@ export function clipboardHasFileNameW(): boolean { */ export function clipboardFilesContentKey(): string | null { try { + if (process.platform === 'darwin') { + const paths = readFileListFast() + return paths?.length ? `files|${paths.join('\n')}` : null + } const buf = clipboard.readBuffer(CF_FILE_LIST) if (!buf || buf.length < 4) return null const fromName = buf @@ -178,7 +224,8 @@ function clipboardAdvertisesFileList(): boolean { try { return clipboard.availableFormats().some((f) => { const l = f.toLowerCase() - return l === 'filenamew' || l === 'filename' || l.includes('shell idlist') + return l === 'filenamew' || l === 'filename' || l.includes('shell idlist') || + isMacFileListFormat(l) }) } catch { return false diff --git a/electron/main/fullscreen.ts b/electron/main/fullscreen.ts index be3f2c6..ac2cde1 100644 --- a/electron/main/fullscreen.ts +++ b/electron/main/fullscreen.ts @@ -14,6 +14,7 @@ * the panel suppresses itself after a game goes fullscreen, which is fine. */ import koffi from 'koffi' +import { isMacFullscreenAppActive } from './macos' // Windows QUERY_USER_NOTIFICATION_STATE enum values: // 1 = QUNS_NOT_PRESENT (screen saver / locked) @@ -70,6 +71,7 @@ if (process.platform === 'win32') { let isFullscreenActiveCache = false let checkTimer: ReturnType | null = null let onFullscreenDetectedFn: (() => void) | null = null +let macCheckInFlight = false export function registerFullscreenActiveListener(fn: () => void): void { onFullscreenDetectedFn = fn @@ -112,6 +114,17 @@ export function isFullscreenAppActive(): boolean { } export function triggerFullscreenCheck(): void { + if (process.platform === 'darwin') { + if (macCheckInFlight) return + macCheckInFlight = true + void isMacFullscreenAppActive().then((isNowFullscreen) => { + isFullscreenActiveCache = isNowFullscreen + if (isNowFullscreen) onFullscreenDetectedFn?.() + }).finally(() => { + macCheckInFlight = false + }) + return + } if (process.platform !== 'win32') return const state = queryNotificationState() if (state < 0) return // koffi unavailable or call failed @@ -143,7 +156,7 @@ export function triggerFullscreenCheck(): void { const FULLSCREEN_CHECK_INTERVAL_MS = 800 export function startFullscreenMonitor(): void { - if (process.platform !== 'win32') return + if (process.platform !== 'win32' && process.platform !== 'darwin') return if (checkTimer !== null) return triggerFullscreenCheck() // seed cache immediately diff --git a/electron/main/geometry.ts b/electron/main/geometry.ts index d7aa70d..132ea2b 100644 --- a/electron/main/geometry.ts +++ b/electron/main/geometry.ts @@ -33,6 +33,28 @@ export interface StickBoundsResult { resolvedDisplay: DisplayInfo } +/** + * Keep a real native drop target on the screen edge while the macOS panel is + * visually collapsed. macOS chooses a destination window during a Finder drag; + * a full-size click-through window cannot reliably become that destination + * halfway through the drag. The narrow strip stays registered without blocking + * the desktop beyond the configured hot zone. + */ +export function computeCollapsedWindowBounds( + bounds: { x: number; y: number; width: number; height: number }, + position: StickPosition, + hotZoneWidth: number +): { x: number; y: number; width: number; height: number } { + const requestedWidth = Number.isFinite(hotZoneWidth) ? Math.round(hotZoneWidth) : 1 + const width = Math.max(1, Math.min(requestedWidth, bounds.width)) + return { + x: position === 'right' ? bounds.x + bounds.width - width : bounds.x, + y: bounds.y, + width, + height: bounds.height + } +} + /** * Tolerance (pixels) for workArea fuzzy-match. * diff --git a/electron/main/index.ts b/electron/main/index.ts index 7c845fb..ac900a7 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -35,6 +35,12 @@ import { getThumbnailPayload, thumbnailCacheControl } from './thumbnailCache' // Electron requires this call before the ready event. app.disableHardwareAcceleration() +// Edge-Drop is a menu-bar utility on macOS. Keep it out of the Dock and app +// switcher while its edge panel remains available across Spaces. +if (process.platform === 'darwin' && typeof app.setActivationPolicy === 'function') { + app.setActivationPolicy('accessory') +} + // Restrict the renderer to a single webContents and forbid remote module usage. app.enableSandbox() @@ -93,7 +99,7 @@ app.on('before-quit', () => { app.whenReady().then(() => { // GitHub NSIS needs an explicit AUMID. Store packages already have one from // the AppX identity; overriding it breaks toasts and taskbar grouping. - if (!isStoreBuild()) { + if (process.platform === 'win32' && !isStoreBuild()) { app.setAppUserModelId('com.edgedrop.app') } diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index c7d4774..1717879 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -25,6 +25,8 @@ import { isStoreBuild } from './config' import { applyLaunchAtLogin, refreshLaunchAtLoginFromOs } from './loginItems' import { toUnpackagedFilePath, toUnpackagedFilePaths } from '../store/paths' import { isPasteableEmoji } from '../../shared/emoji' +import { simulateMacPaste, writeMacClipboardFiles, writeMacClipboardImage } from './macos' +import { filePathsFromUriList } from '../../shared/dropPayload' export { isStoreBuild } @@ -71,6 +73,9 @@ function simulatePaste(): void { }) }) } + if (process.platform === 'darwin') { + void simulateMacPaste() + } } /** @@ -107,6 +112,9 @@ async function writeFileListToClipboard(rawPaths: string[]): Promise { console.error('[ipc] writeFileListToClipboard PowerShell failed, using text fallback:', err) } } + if (process.platform === 'darwin') { + return writeMacClipboardFiles(validPaths) + } // Non-Windows / PowerShell failure fallback: plain text paths (best-effort) clipboard.clear() clipboard.writeText(validPaths.join('\r\n')) @@ -141,6 +149,9 @@ export async function writeImageToClipboard(imagePath: string | null): Promise { + if (process.platform === 'darwin') { + return writeMacClipboardImage(imagePath, [namedPath]) + } if (process.platform !== 'win32') return false try { const b64Img = Buffer.from(imagePath, 'utf8').toString('base64') @@ -554,14 +565,11 @@ export function registerIpc(): void { if (data.kind === 'image' && (data as any).imageUrl) { const imageUrl = (data as any).imageUrl as string if (/^file:/i.test(imageUrl)) { - const local = imageUrl.replace(/^file:\/\//i, '').replace(/^\/([a-zA-Z]:)/, '$1') - try { - const decoded = decodeURIComponent(local).replace(/\//g, '\\') - if (existsSync(decoded)) { - addFiles([decoded]) - return getStore().toDto() - } - } catch { /* fall through to bitmap import */ } + const localPaths = filterValidPaths(filePathsFromUriList(imageUrl, process.platform)) + if (localPaths.length > 0) { + addFiles(localPaths) + return getStore().toDto() + } } try { let img = nativeImage.createFromDataURL(imageUrl) @@ -860,6 +868,13 @@ export async function writeItemToClipboard(data: ItemData, capturedAt?: number): return writeFileListToClipboard(stagedFiles) } + if (process.platform === 'darwin') { + return writeMacClipboardImage( + toUnpackagedFilePath(firstSrc), + stagedFiles.map((p) => toUnpackagedFilePath(p)) + ) + } + // Multi-file: all pretty-named refs + first image as bitmap. try { const exposed = stagedFiles.map((p) => toUnpackagedFilePath(p)) diff --git a/electron/main/loginItems.ts b/electron/main/loginItems.ts index 910f567..1f44c7e 100644 --- a/electron/main/loginItems.ts +++ b/electron/main/loginItems.ts @@ -33,6 +33,33 @@ export interface LaunchAtLoginResult { ok: boolean } +function readMacLaunchAtLogin(): LaunchAtLoginResult { + try { + const settings = app.getLoginItemSettings() + return { + enabled: settings.openAtLogin || settings.executableWillLaunchAtLogin, + blockedByUser: false, + ok: true + } + } catch { + return { enabled: false, blockedByUser: false, ok: false } + } +} + +function applyMacLaunchAtLogin(wantLaunch: boolean): LaunchAtLoginResult { + try { + app.setLoginItemSettings({ + openAtLogin: wantLaunch, + openAsHidden: true + }) + const actual = readMacLaunchAtLogin() + return { ...actual, ok: actual.ok && actual.enabled === wantLaunch } + } catch (err) { + console.error('[LoginItems] macOS login-item update failed:', err) + return { enabled: !wantLaunch, blockedByUser: false, ok: false } + } +} + export function normalizeLoginPath(p: string): string { let s = p.trim().replace(/\//g, '\\').toLowerCase() if (s.startsWith('"')) { @@ -383,6 +410,9 @@ export async function readLaunchAtLogin(): Promise { if (!app.isPackaged) { return { enabled: loadSettings().launchAtLogin, blockedByUser: false, ok: true } } + if (process.platform === 'darwin') { + return readMacLaunchAtLogin() + } if (isStoreBuild()) { return resultFromState(await getStatus()) } @@ -397,6 +427,9 @@ export async function applyLaunchAtLogin(wantLaunch: boolean): Promise { if (!os.ok) { // GitHub: even when the Electron query fails, a healthy raw key means // we are actually fine. Heal the quoting/stale path opportunistically. - if (settings.launchAtLogin && !isStoreBuild()) { + if (process.platform === 'win32' && settings.launchAtLogin && !isStoreBuild()) { try { if (!isGithubRunKeyHealthy()) { applyGithubLaunchAtLogin(true) @@ -446,7 +479,7 @@ export async function reconcileLaunchAtLoginOnStartup(): Promise { // If user has settings=false, but the Run key exists for Edge-Drop and is NOT blocked by user: // This happens when 0.3.1's false-negative poll bug erroneously saved launchAtLogin: false. // Self-heal: restore settings to true and ensure key is properly quoted! - if (!isStoreBuild()) { + if (process.platform === 'win32' && !isStoreBuild()) { const exe = app.getPath('exe') const raw = getRawGithubRunCommand(CANONICAL_LOGIN_ITEM_NAME) if (raw && isOurLoginExe(raw, exe) && !isBlockedInStartupApproved(CANONICAL_LOGIN_ITEM_NAME)) { @@ -484,7 +517,7 @@ export async function reconcileLaunchAtLoginOnStartup(): Promise { } } - if (settings.launchAtLogin && os.enabled && !isStoreBuild()) { + if (process.platform === 'win32' && settings.launchAtLogin && os.enabled && !isStoreBuild()) { // Self-heal quoting / stale path / missing --hidden on every launch so // users updating from 0.3.0 (unquoted) get fixed without touching UI. try { diff --git a/electron/main/macos.ts b/electron/main/macos.ts new file mode 100644 index 0000000..790896b --- /dev/null +++ b/electron/main/macos.ts @@ -0,0 +1,81 @@ +import { app } from 'electron' +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +function helperPath(): string | null { + if (process.platform !== 'darwin') return null + const candidate = app.isPackaged + ? join(process.resourcesPath, 'macos', 'EdgeDropMacHelper') + : join(app.getAppPath(), 'resources', 'macos', 'bin', 'EdgeDropMacHelper') + return existsSync(candidate) ? candidate : null +} + +async function runHelper(args: string[], timeout = 3000): Promise { + const helper = helperPath() + if (!helper) throw new Error('macOS helper is not built') + const { stdout } = await execFileAsync(helper, args, { + encoding: 'utf8', + timeout, + maxBuffer: 1024 * 1024 + }) + return stdout +} + +export async function readMacClipboardFiles(): Promise { + if (process.platform !== 'darwin') return null + try { + const raw = await runHelper(['read-files']) + const value: unknown = JSON.parse(raw || '[]') + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : null + } catch (err) { + console.error('[macOS] Failed to read pasteboard file URLs:', err) + return null + } +} + +export async function writeMacClipboardFiles(paths: string[]): Promise { + if (process.platform !== 'darwin' || paths.length === 0) return false + try { + await runHelper(['write-files', ...paths]) + return true + } catch (err) { + console.error('[macOS] Failed to write pasteboard file URLs:', err) + return false + } +} + +export async function writeMacClipboardImage(imagePath: string, filePaths: string[] = []): Promise { + if (process.platform !== 'darwin') return false + try { + await runHelper(['write-image', imagePath, ...filePaths]) + return true + } catch (err) { + console.error('[macOS] Failed to write pasteboard image:', err) + return false + } +} + +export async function simulateMacPaste(): Promise { + if (process.platform !== 'darwin') return false + try { + await runHelper(['paste'], 5000) + return true + } catch (err) { + console.error('[macOS] Command+V simulation failed:', err) + return false + } +} + +export async function isMacFullscreenAppActive(): Promise { + if (process.platform !== 'darwin') return false + try { + return (await runHelper(['frontmost-fullscreen'], 1500)).trim() === '1' + } catch (err) { + console.error('[macOS] Fullscreen detection failed:', err) + return false + } +} diff --git a/electron/main/tray.ts b/electron/main/tray.ts index b9b7960..ad104e5 100644 --- a/electron/main/tray.ts +++ b/electron/main/tray.ts @@ -65,6 +65,17 @@ export function isTaskbarLightTheme(): boolean { /** Resolves the appropriate 32x32 tray icon based on the current taskbar theme. */ export function getTrayImage(): Electron.NativeImage { + if (process.platform === 'darwin') { + const source = existsSync(PATHS.trayIcon()) + ? nativeImage.createFromPath(PATHS.trayIcon()) + : fallbackIcon() + const image = source.resize({ width: 18, height: 18, quality: 'best' }) + if (typeof image.setTemplateImage === 'function') { + image.setTemplateImage(true) + return image + } + } + const isLight = isTaskbarLightTheme() const preferredPath = isLight ? PATHS.trayDarkIcon() : PATHS.trayIcon() const fallbackPath = PATHS.trayIcon() diff --git a/electron/main/updater.ts b/electron/main/updater.ts index 3ff619d..f7fac57 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -177,6 +177,12 @@ export function initAutoUpdater(): void { console.log('[AutoUpdater] Store build detected — auto-updater disabled.') return } + if (process.platform === 'darwin') { + // The fork has no signed/notarized macOS release feed yet. Avoid a noisy + // request to the Windows-only upstream release on every launch. + console.log('[AutoUpdater] macOS release feed not configured — automatic updates disabled.') + return + } try { const { autoUpdater } = require('electron-updater') diff --git a/electron/main/window.ts b/electron/main/window.ts index b7f45ab..0fb1750 100644 --- a/electron/main/window.ts +++ b/electron/main/window.ts @@ -1,14 +1,11 @@ /** * The edge panel BrowserWindow. * - * The window is the full *expanded* size and sits at the edge of the stick - * display's work area. It is transparent and frameless, and is normally - * click-through (`setIgnoreMouseEvents(true, { forward: false })`) so the - * desktop stays fully usable. Edge detection does NOT rely on DOM pointer - * events: the main-process cursor poll (startCursorPoll) reads the OS cursor - * position directly every tick, which also keeps working during OS file - * drags — the edge dwell opens the panel and makes the main window - * interactive, and the drop then lands on the main window. + * The transparent, frameless window sits at the stick display's edge. Windows + * keeps the expanded window click-through while collapsed. macOS instead keeps + * a hot-zone-width native strip alive so Finder can register it as a drop + * destination before the shelf expands. Edge detection itself comes from the + * main-process cursor poll and does not depend on DOM pointer events. * * NOTE: this module must NOT import from state.ts to avoid circular dependencies. */ @@ -19,7 +16,7 @@ import { APP_CONFIG } from './config' import { runtime } from './config' import { PATHS } from '../store/paths' import { TRANSLATIONS, en } from '../../src/i18n/translations' -import { computeStickBounds } from './geometry' +import { computeCollapsedWindowBounds, computeStickBounds } from './geometry' import { WorkAreaCache } from './workAreaCache' import { probeSeamAware, isNearProximity, type SeamTickState } from './stickProbe' import { loadSettings, saveSettings } from '../store/settings' @@ -63,6 +60,11 @@ if (process.platform === 'win32') { const GWL_EXSTYLE = -20 const WS_EX_NOACTIVATE = 0x08000000 +const SHELF_WINDOW_LEVEL = process.platform === 'darwin' ? 'floating' : 'screen-saver' + +function keepShelfOnTop(win: BrowserWindow): void { + win.setAlwaysOnTop(true, SHELF_WINDOW_LEVEL) +} function getHwnd(win: BrowserWindow | null): number | bigint { if (!win || win.isDestroyed()) return 0 @@ -135,6 +137,7 @@ export function updateCachedWorkArea(): void { export function setHotZoneWidth(width: number): void { currentHotZoneWidth = width + if (process.platform === 'darwin' && !interactive) repositionWindow() } export function setStickDisplayId(id: number | undefined): void { @@ -169,10 +172,9 @@ export function isInteractive(): boolean { /** * Toggle whether the panel swallows pointer events. * - * - interactive=false (collapsed) -> click-through: Windows passes ALL mouse - * clicks to apps beneath. Edge detection is done by the main-process cursor - * poll (startCursorPoll), which reads screen.getCursorScreenPoint() directly, - * so no mouse-event forwarding is needed. + * - interactive=false (collapsed) -> Windows is click-through; macOS shrinks + * to the configured edge hot zone so Finder still sees a native drop target. + * Edge detection remains driven by the main-process cursor poll. * - interactive=true (expanded) -> normal interactive window: the black blade * captures all clicks. */ @@ -180,17 +182,28 @@ export function setInteractive(value: boolean): void { if (!mainWindow || value === interactive) return interactive = value if (value) { + // On macOS the collapsed window is a narrow, live native drop target. + // Expand that same window before the Finder drag crosses into the shelf. + if (process.platform === 'darwin') mainWindow.setBounds(getStickGeometry()) // Panel is open: disable click-through so user can interact. mainWindow.setIgnoreMouseEvents(false) - // Use 'screen-saver' level to stay above fullscreen apps (YouTube fullscreen, games, etc.) - // 'floating' (HWND_TOPMOST) can be pushed behind by fullscreen D3D/browser windows. - mainWindow.setAlwaysOnTop(true, 'screen-saver') + // Windows uses screen-saver level for fullscreen apps. macOS uses floating: + // it stays above ordinary windows without covering Finder's drag layer. + keepShelfOnTop(mainWindow) mainWindow.setSkipTaskbar(true) applyNoActivateStyle(mainWindow, true) } else { - // Panel is closed: full click-through, no forwarding needed. - mainWindow.setIgnoreMouseEvents(true, { forward: false }) - mainWindow.setAlwaysOnTop(true, 'screen-saver') + if (process.platform === 'darwin') { + // Finder locks onto native destination windows during a drag. Keep a + // hot-zone-width strip interactive so a drag that began while collapsed + // can enter the renderer, then expand it when the edge dwell opens. + mainWindow.setIgnoreMouseEvents(false) + mainWindow.setBounds(getNativeWindowGeometry()) + } else { + // Panel is closed: full click-through, no forwarding needed. + mainWindow.setIgnoreMouseEvents(true, { forward: false }) + } + keepShelfOnTop(mainWindow) mainWindow.setSkipTaskbar(true) applyNoActivateStyle(mainWindow, true) @@ -270,14 +283,14 @@ let _seamState: SeamTickState = {} export function setHeartbeatPaused(paused: boolean): void { heartbeatPaused = paused if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { - if (paused) { + if (paused && process.platform === 'win32') { // Temporarily lower window z-band from 'screen-saver' to 'normal' during active drag // so the Windows DWM drag-ghost image renders ON TOP of our window. mainWindow.setAlwaysOnTop(true, 'normal') } else { // Re-assert z-order immediately when drag ends so the window snaps back // to the correct level without waiting for the next heartbeat tick. - mainWindow.setAlwaysOnTop(true, 'screen-saver') + keepShelfOnTop(mainWindow) } } } @@ -518,20 +531,26 @@ function getStickGeometry(): { x: number; y: number; width: number; height: numb return { x: result.x, y: result.y, width: result.width, height: result.height } } +function getNativeWindowGeometry(): { x: number; y: number; width: number; height: number } { + const fullBounds = getStickGeometry() + if (process.platform !== 'darwin' || interactive || previewActive) return fullBounds + return computeCollapsedWindowBounds(fullBounds, loadSettings().stickPosition, currentHotZoneWidth) +} + export function createWindow(): BrowserWindow { - const { x, y, height } = getStickGeometry() + const initialBounds = getNativeWindowGeometry() mainWindow = new BrowserWindow({ icon: PATHS.icon(), - x, - y, - width: PANEL_WIDTH, - height, + x: initialBounds.x, + y: initialBounds.y, + width: initialBounds.width, + height: initialBounds.height, show: false, frame: false, fullscreenable: false, maximizable: false, - minWidth: PANEL_WIDTH, + minWidth: process.platform === 'darwin' ? 1 : PANEL_WIDTH, minHeight: 320, movable: false, resizable: false, @@ -539,7 +558,9 @@ export function createWindow(): BrowserWindow { hasShadow: false, skipTaskbar: true, alwaysOnTop: true, - focusable: false, + // A non-focusable NSPanel is not a reliable Finder drag destination. + // showInactive() still prevents activation when the shelf merely appears. + focusable: process.platform === 'darwin', backgroundColor: '#00000000', roundedCorners: false, webPreferences: { @@ -551,8 +572,18 @@ export function createWindow(): BrowserWindow { } }) - // Start click-through with no forwarding — edge detection is done via cursor poll. - mainWindow.setIgnoreMouseEvents(true, { forward: false }) + // macOS needs a real edge strip registered as a Finder drop destination. + // Other platforms retain the original full-size click-through behavior. + if (process.platform === 'darwin') mainWindow.setIgnoreMouseEvents(false) + else mainWindow.setIgnoreMouseEvents(true, { forward: false }) + + if (process.platform === 'darwin') { + mainWindow.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true + }) + mainWindow.setHiddenInMissionControl(true) + } // Apply WS_EX_NOACTIVATE so clicking the panel never steals OS focus from the active application. applyNoActivateStyle(mainWindow, true) @@ -648,7 +679,7 @@ export function createWindow(): BrowserWindow { // Respect OS-level always-on-top reordering. mainWindow.on('focus', () => { - mainWindow?.setAlwaysOnTop(true, 'screen-saver') + if (mainWindow) keepShelfOnTop(mainWindow) }) // Open external links in the default browser. @@ -667,8 +698,8 @@ export function createWindow(): BrowserWindow { mainWindow.once('ready-to-show', () => { if (!mainWindow) return mainWindow.showInactive() - // 'screen-saver' level stays above fullscreen browser windows and games. - mainWindow.setAlwaysOnTop(true, 'screen-saver') + // On macOS, floating stays above apps but below Finder's drag image. + keepShelfOnTop(mainWindow) applyNoActivateStyle(mainWindow, true) }) @@ -695,7 +726,7 @@ export function createWindow(): BrowserWindow { heartbeatTimer = setInterval(() => { if (runtime.quitting || heartbeatPaused || interactive) return if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { - mainWindow.setAlwaysOnTop(true, 'screen-saver') + keepShelfOnTop(mainWindow) } }, 2000) @@ -834,7 +865,7 @@ export function popUpAndRetract(durationMs = 1500): void { if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.webContents || mainWindow.webContents.isDestroyed()) return if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.showInactive() - mainWindow.setAlwaysOnTop(true, 'screen-saver') + keepShelfOnTop(mainWindow) mainWindow.setSkipTaskbar(true) const wasAlreadyOpen = interactive @@ -857,9 +888,9 @@ export function repositionWindow(): void { if (!mainWindow || mainWindow.isDestroyed()) return if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.showInactive() - mainWindow.setAlwaysOnTop(true, 'screen-saver') + keepShelfOnTop(mainWindow) mainWindow.setSkipTaskbar(true) - const g = getStickGeometry() + const g = getNativeWindowGeometry() mainWindow.setBounds({ ...g }) onWindowRepositioned?.() } @@ -869,7 +900,7 @@ export function setVisible(visible: boolean): void { if (!mainWindow) return if (visible) { mainWindow.showInactive() - mainWindow.setAlwaysOnTop(true, 'screen-saver') + keepShelfOnTop(mainWindow) mainWindow.setSkipTaskbar(true) } else { mainWindow.hide() diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f5f5eb0..b8e0cfc 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -19,6 +19,7 @@ import type { } from '../../shared/ipc' import type { EdgeApi } from '../../shared/bridge' import type { DragRequest } from '../../shared/types' +import { filePathsFromUriList } from '../../shared/dropPayload' /** Typed invoke wrapper derived from the shared contracts. */ function invoke( @@ -134,6 +135,15 @@ win.addEventListener('drop', (e: any) => { const plainText = dt.getData('text/plain')?.trim() const htmlText = dt.getData('text/html')?.trim() + // Finder can expose only text/uri-list on macOS, leaving DataTransfer.files + // empty. Decode those file URLs before treating the payload as a web URL. + const uriFilePaths = filePathsFromUriList(uriList, process.platform) + if (uriFilePaths.length > 0) { + e.preventDefault() + invoke('item:add-files', uriFilePaths).catch(console.error) + return + } + if (uriList) { const urls = uriList.split(/\r?\n/).map((u: string) => u.trim()).filter((u: string) => u && !u.startsWith('#')) if (urls.length > 0) { @@ -176,6 +186,8 @@ win.addEventListener('drop', (e: any) => { }, true) const api = { + platform: process.platform, + /* Renderer -> Main */ loadState: () => invoke('state:load'), setPinned: (id: string, pinned: boolean) => invoke('item:set-pinned', id, pinned), diff --git a/index.html b/index.html index 38aff56..8324959 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + Edge-Drop diff --git a/package.json b/package.json index c199912..784f09e 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,15 @@ "type": "module", "scripts": { "dev": "electron-vite dev", + "dev:mac": "npm run build:mac-helper && electron-vite dev", "build": "electron-vite build", "build:github": "npm run build && electron-builder --win nsis -c.extraMetadata.buildTarget=github", "build:store": "npm run build && electron-builder --win appx -c.extraMetadata.buildTarget=store", "build:msix": "npm run build && electron-builder --win appx -c.extraMetadata.buildTarget=store", "build:win": "npm run build:github", + "build:mac-helper": "bash resources/macos/build-helper.sh", + "build:mac": "npm run build:mac-helper && npm run build && electron-builder --mac dmg zip --publish never -c.mac.identity=null -c.mac.hardenedRuntime=false", + "build:mac:release": "npm run build:mac-helper && npm run build && electron-builder --mac dmg zip --publish never", "package": "npm run build:github", "preview": "electron-vite preview", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", @@ -49,10 +53,6 @@ "!**/node_modules/koffi/doc/**" ], "extraResources": [ - { - "from": "resources/startup/EdgeDropStartup.exe", - "to": "startup/EdgeDropStartup.exe" - }, { "from": "node_modules/emoji-datasource-twitter/img/twitter/64", "to": "emoji/64" @@ -84,7 +84,31 @@ ] } ], - "icon": "resources/icon.ico" + "icon": "resources/icon.ico", + "extraResources": [ + { + "from": "resources/startup/EdgeDropStartup.exe", + "to": "startup/EdgeDropStartup.exe" + } + ] + }, + "mac": { + "target": [ + "dmg", + "zip" + ], + "category": "public.app-category.productivity", + "icon": "resources/icon-mac.png", + "artifactName": "Edge-Drop-${version}-${arch}.${ext}", + "extendInfo": { + "LSUIElement": true + }, + "extraResources": [ + { + "from": "resources/macos/bin/EdgeDropMacHelper", + "to": "macos/EdgeDropMacHelper" + } + ] }, "nsis": { "oneClick": false, @@ -140,4 +164,4 @@ "react": "^18.2.0", "zustand": "^4.5.2" } -} \ No newline at end of file +} diff --git a/resources/icon-mac.png b/resources/icon-mac.png new file mode 100644 index 0000000..c7bd10a Binary files /dev/null and b/resources/icon-mac.png differ diff --git a/resources/macos/EdgeDropMacHelper.swift b/resources/macos/EdgeDropMacHelper.swift new file mode 100644 index 0000000..53e20aa --- /dev/null +++ b/resources/macos/EdgeDropMacHelper.swift @@ -0,0 +1,115 @@ +import AppKit +import ApplicationServices +import Foundation + +private func emitJSON(_ value: Any) throws { + let data = try JSONSerialization.data(withJSONObject: value) + FileHandle.standardOutput.write(data) +} + +private func readFiles() throws { + let options: [NSPasteboard.ReadingOptionKey: Any] = [ + .urlReadingFileURLsOnly: true + ] + let objects = NSPasteboard.general.readObjects( + forClasses: [NSURL.self], + options: options + ) as? [URL] ?? [] + try emitJSON(objects.filter(\.isFileURL).map(\.path)) +} + +@discardableResult +private func writeFiles(_ paths: ArraySlice) -> Bool { + let urls = paths.map { NSURL(fileURLWithPath: $0) } + guard !urls.isEmpty else { return false } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + return pasteboard.writeObjects(urls) +} + +@discardableResult +private func writeImage(imagePath: String, filePaths: ArraySlice) -> Bool { + guard let image = NSImage(contentsOfFile: imagePath) else { return false } + var objects: [NSPasteboardWriting] = [image] + objects.append(contentsOf: filePaths.map { NSURL(fileURLWithPath: $0) }) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + return pasteboard.writeObjects(objects) +} + +private func requestAccessibilityIfNeeded() -> Bool { + let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + return AXIsProcessTrustedWithOptions([key: true] as CFDictionary) +} + +private func paste() -> Bool { + guard requestAccessibilityIfNeeded() else { return false } + guard + let source = CGEventSource(stateID: .combinedSessionState), + let down = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true), + let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) + else { return false } + + down.flags = .maskCommand + up.flags = .maskCommand + down.post(tap: .cghidEventTap) + usleep(20_000) + up.post(tap: .cghidEventTap) + return true +} + +private func frontmostAppIsFullscreen() -> Bool { + guard let app = NSWorkspace.shared.frontmostApplication else { return false } + guard let windows = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] else { return false } + + let tolerance: CGFloat = 3 + for window in windows { + guard (window[kCGWindowOwnerPID as String] as? pid_t) == app.processIdentifier else { continue } + guard (window[kCGWindowLayer as String] as? Int) == 0 else { continue } + guard let boundsDictionary = window[kCGWindowBounds as String] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: boundsDictionary) else { continue } + + for screen in NSScreen.screens { + guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { continue } + let frame = CGDisplayBounds(CGDirectDisplayID(screenNumber.uint32Value)) + let sameOrigin = abs(bounds.minX - frame.minX) <= tolerance && + abs(bounds.minY - frame.minY) <= tolerance + let sameSize = abs(bounds.width - frame.width) <= tolerance && + abs(bounds.height - frame.height) <= tolerance + if sameOrigin && sameSize { return true } + } + } + return false +} + +do { + let args = CommandLine.arguments + guard args.count >= 2 else { throw NSError(domain: "EdgeDropMacHelper", code: 2) } + + let ok: Bool + switch args[1] { + case "read-files": + try readFiles() + ok = true + case "write-files": + ok = writeFiles(args.dropFirst(2)) + case "write-image": + guard args.count >= 3 else { throw NSError(domain: "EdgeDropMacHelper", code: 2) } + ok = writeImage(imagePath: args[2], filePaths: args.dropFirst(3)) + case "paste": + ok = paste() + case "frontmost-fullscreen": + print(frontmostAppIsFullscreen() ? "1" : "0") + ok = true + default: + throw NSError(domain: "EdgeDropMacHelper", code: 2) + } + + if !ok { exit(1) } +} catch { + FileHandle.standardError.write(Data("\(error)\n".utf8)) + exit(1) +} diff --git a/resources/macos/build-helper.sh b/resources/macos/build-helper.sh new file mode 100644 index 0000000..d25ded9 --- /dev/null +++ b/resources/macos/build-helper.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +OUTPUT_DIR="$SCRIPT_DIR/bin" +SDK_PATH="$(xcrun --sdk macosx --show-sdk-path)" + +mkdir -p "$BUILD_DIR" "$OUTPUT_DIR" + +swiftc -O -sdk "$SDK_PATH" -target arm64-apple-macos12.0 \ + "$SCRIPT_DIR/EdgeDropMacHelper.swift" -o "$BUILD_DIR/EdgeDropMacHelper-arm64" +swiftc -O -sdk "$SDK_PATH" -target x86_64-apple-macos12.0 \ + "$SCRIPT_DIR/EdgeDropMacHelper.swift" -o "$BUILD_DIR/EdgeDropMacHelper-x64" +lipo -create \ + "$BUILD_DIR/EdgeDropMacHelper-arm64" \ + "$BUILD_DIR/EdgeDropMacHelper-x64" \ + -output "$OUTPUT_DIR/EdgeDropMacHelper" + +codesign --force --sign - "$OUTPUT_DIR/EdgeDropMacHelper" diff --git a/shared/bridge.ts b/shared/bridge.ts index 7edb358..2a70986 100644 --- a/shared/bridge.ts +++ b/shared/bridge.ts @@ -9,6 +9,8 @@ import type { Settings } from './types' import type { DragRequest } from './types' export interface EdgeApi { + readonly platform: 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd' + /* Renderer -> Main */ loadState: () => Promise<{ items: import('./types').ClipboardItemDto[]; settings: Settings; version: string; isStoreBuild?: boolean }> setPinned: (id: string, pinned: boolean) => Promise diff --git a/shared/dropPayload.ts b/shared/dropPayload.ts new file mode 100644 index 0000000..a04c34f --- /dev/null +++ b/shared/dropPayload.ts @@ -0,0 +1,51 @@ +/** Clipboard/HTML drag payload helpers shared by the renderer and preload. */ + +/** + * macOS Finder commonly exposes a drag as `text/uri-list` even when Chromium + * does not populate DataTransfer.files. Recognize all useful external payloads + * so the edge panel opens and stays interactive during the gesture. + */ +export function hasExternalDragPayload(types: Iterable): boolean { + const normalized = new Set(Array.from(types, (type) => type.toLowerCase())) + return normalized.has('files') || + normalized.has('text/uri-list') || + normalized.has('public.file-url') || + normalized.has('text/plain') || + normalized.has('text/html') || + normalized.has('url') +} + +/** Decode local file URLs from an RFC 2483/Chromium URI-list payload. */ +export function filePathsFromUriList(raw: string, platform: string): string[] { + const paths: string[] = [] + const seen = new Set() + + for (const line of raw.split(/\r?\n/)) { + const value = line.trim() + if (!value || value.startsWith('#')) continue + + try { + const url = new URL(value) + if (url.protocol !== 'file:') continue + + let path = decodeURIComponent(url.pathname) + if (platform === 'win32') { + path = path.replace(/^\/([a-zA-Z]:)/, '$1').replace(/\//g, '\\') + if (url.hostname && url.hostname !== 'localhost') { + path = `\\\\${url.hostname}${path.startsWith('\\') ? '' : '\\'}${path}` + } + } else if (url.hostname && url.hostname !== 'localhost') { + path = `//${url.hostname}${path}` + } + + if (path && !seen.has(path)) { + seen.add(path) + paths.push(path) + } + } catch { + // Ignore malformed or non-URL lines; callers may still handle them as text. + } + } + + return paths +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 164453e..109c9e5 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -9,6 +9,7 @@ import { useTranslation } from '../i18n' export function Header() { const { t } = useTranslation() + const isMac = window.edge?.platform === 'darwin' const setSettingsOpen = useStore((s) => s.setSettingsOpen) const settingsOpen = useStore((s) => s.settingsOpen) const updateInfo = useStore((s) => s.updateInfo) @@ -49,7 +50,10 @@ export function Header() { const activeId: (typeof FILTERS)[number]['id'] = emojiOpen ? 'emoji' : typeFilter const activeIndex = Math.max(0, FILTERS.findIndex((f) => f.id === activeId)) const ActiveIcon = FILTERS[activeIndex]?.Icon || FILTERS[0].Icon - const filterChipWidth = 28 + const filterChipWidth = isMac ? 26 : 28 + const filterGap = isMac ? 3 : 4 + const filterIconSize = isMac ? 13 : 14 + const headerButtonSize = isMac ? 28 : 32 const reduceMotion = !!settings.reduceMotion const headerFade = `opacity ${reduceMotion ? '0.01s' : '0.16s'} ease` @@ -78,7 +82,7 @@ export function Header() { border: 'none', borderRadius: 999, padding: 0, - gap: 4, + gap: filterGap, marginLeft: 0, maxWidth: '100%', overflow: 'visible', @@ -90,7 +94,7 @@ export function Header() { {/* Single Persistent Sliding Pill Indicator (ABOVE the buttons) */} - + {FILTERS.map((f) => { @@ -140,7 +144,7 @@ export function Header() { else setTypeFilter(f.id) }} > - + ) })} @@ -180,13 +184,10 @@ export function Header() { }} style={{ color: 'rgba(255, 255, 255, 0.75)', - background: 'transparent', - border: 'none', - boxShadow: 'none', flexShrink: 0, cursor: 'pointer', - width: 32, - height: 32, + width: headerButtonSize, + height: headerButtonSize, display: 'grid', placeItems: 'center', position: 'relative', @@ -194,7 +195,7 @@ export function Header() { transition: 'all 0.15s ease' }} > - + {isChangelogUnread && ( - + - + {!settingsOpen && (updateInfo?.downloaded || ((settings.autoUpdates ?? true) && updateInfo?.hasUpdate)) && ( void } +const isMac = edge.platform === 'darwin' + /** Formats an Electron accelerator string (e.g. "Alt+Shift+C") into individual display keys. */ function parseKeyBadges(accelerator: string): string[] { if (!accelerator) return ['Alt', 'C'] @@ -16,8 +18,10 @@ function parseKeyBadges(accelerator: string): string[] { .split('+') .map((k) => { const trimmed = k.trim() - if (trimmed === 'CommandOrControl' || trimmed === 'Ctrl') return 'Ctrl' - if (trimmed === 'Meta' || trimmed === 'Super' || trimmed === 'Command') return 'Win' + if (trimmed === 'CommandOrControl') return isMac ? 'Command' : 'Ctrl' + if (trimmed === 'Ctrl') return isMac ? 'Control' : 'Ctrl' + if (trimmed === 'Alt') return isMac ? 'Option' : 'Alt' + if (trimmed === 'Meta' || trimmed === 'Super' || trimmed === 'Command') return isMac ? 'Command' : 'Win' return trimmed.length === 1 ? trimmed.toUpperCase() : trimmed }) } @@ -29,7 +33,7 @@ function eventToAccelerator(e: KeyboardEvent): { accelerator: string; isValid: b if (e.ctrlKey) modifiers.push('Ctrl') if (e.altKey) modifiers.push('Alt') if (e.shiftKey) modifiers.push('Shift') - if (e.metaKey) modifiers.push('Super') + if (e.metaKey) modifiers.push(isMac ? 'Command' : 'Super') // Identify main non-modifier key let keyName = '' @@ -80,7 +84,13 @@ function eventToAccelerator(e: KeyboardEvent): { accelerator: string; isValid: b return { accelerator: allParts.join('+'), isValid, - partialBadges: allParts.map(p => (p === 'Super' ? 'Win' : p)) + partialBadges: allParts.map((p) => { + if (p === 'Super') return 'Win' + if (isMac && p === 'Command') return 'Command' + if (isMac && p === 'Alt') return 'Option' + if (isMac && p === 'Ctrl') return 'Control' + return p + }) } } diff --git a/src/components/IndicatorStyleFlyout.tsx b/src/components/IndicatorStyleFlyout.tsx index f72d162..0e7fb53 100644 --- a/src/components/IndicatorStyleFlyout.tsx +++ b/src/components/IndicatorStyleFlyout.tsx @@ -233,16 +233,16 @@ function StyleCard({ justifyContent: 'center', gap: 8, padding: '12px 10px 10px', - background: active ? '#141414' : '#141414', + background: active ? 'rgba(255, 255, 255, 0.055)' : 'transparent', border: 'none', - outline: active ? '2px solid #ffffff' : 'none', + outline: 'none', borderRadius: 16, position: 'relative', cursor: 'pointer', transition: 'all 0.2s cubic-bezier(0.16, 1, 0.3, 1)', userSelect: 'none', overflow: 'hidden', - boxShadow: active ? '0 4px 16px rgba(0, 0, 0, 0.5), 0 0 14px rgba(255, 255, 255, 0.12)' : 'none', + boxShadow: 'none', ...style }} > diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx index d7b546f..dfbee39 100644 --- a/src/components/Panel.tsx +++ b/src/components/Panel.tsx @@ -21,6 +21,7 @@ import { PreviewFlyout } from './PreviewFlyout' import { IndicatorStyleFlyout } from './IndicatorStyleFlyout' import { CopyIndicatorCurve } from './CopyIndicatorCurve' import { useFilteredItems } from '../hooks/useFilteredItems' +import { hasExternalDragPayload } from '../../shared/dropPayload' import { useTranslation } from '../i18n' @@ -162,14 +163,7 @@ export function Panel() { }, [internalDragReq, setInternalDragReq, setDragActive]) const hasDragContent = (e: React.DragEvent) => { - const types = Array.from(e.dataTransfer?.types || []) - return ( - types.includes('Files') || - types.includes('text/uri-list') || - types.includes('text/plain') || - types.includes('text/html') || - types.includes('URL') - ) + return hasExternalDragPayload(e.dataTransfer?.types || []) } const onDragEnter = (e: React.DragEvent) => { diff --git a/src/hooks/useEdgeHover.ts b/src/hooks/useEdgeHover.ts index d77a8c5..07d98ae 100644 --- a/src/hooks/useEdgeHover.ts +++ b/src/hooks/useEdgeHover.ts @@ -23,6 +23,7 @@ import { useEffect, useRef } from 'react' import { edge } from '../lib/edge' import { useStore } from '../store/appStore' +import { hasExternalDragPayload } from '../../shared/dropPayload' const TRIGGER_PX = 3 // leftmost px that count as "the edge" const DWELL_MS = 40 // cursor must linger this long to open @@ -473,14 +474,14 @@ export function useEdgeHover(): void { // ── OS file drag awareness ───────────────────────────────────────────── const onDocDragEnter = (e: DragEvent) => { - if (e.dataTransfer?.types.includes('Files')) { + if (e.dataTransfer && hasExternalDragPayload(e.dataTransfer.types)) { e.preventDefault() useStore.getState().setDragActive(true) openPanel() } } const onDocDragOver = (e: DragEvent) => { - if (e.dataTransfer?.types.includes('Files')) { + if (e.dataTransfer && hasExternalDragPayload(e.dataTransfer.types)) { e.preventDefault() cancelClose() } diff --git a/src/main.tsx b/src/main.tsx index 16f165b..f3dec4b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -26,6 +26,7 @@ if (!container) throw new Error('#root element not found') // saving massive amounts of GPU fill-rate on 4K/Hi-DPI displays. const dpr = window.devicePixelRatio || 1 document.documentElement.style.setProperty('--dpr', dpr.toString()) +document.documentElement.dataset.platform = window.edge?.platform ?? 'unknown' const root = createRoot(container) diff --git a/src/styles/global.css b/src/styles/global.css index 92b2ec7..f5904d1 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -102,7 +102,22 @@ button { color: inherit; background: none; border: none; + padding: 0; + margin: 0; cursor: pointer; + appearance: none; + -webkit-appearance: none; + -webkit-tap-highlight-color: transparent; + line-height: 1; +} + +button:focus { + outline: none; +} + +button:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.55); + outline-offset: 2px; } input { diff --git a/src/styles/panel.css b/src/styles/panel.css index 6574fb5..539d60c 100644 --- a/src/styles/panel.css +++ b/src/styles/panel.css @@ -145,12 +145,21 @@ background: transparent; border: none; transition: background var(--dur-fast) var(--ease-out), - color var(--dur-fast) var(--ease-out); + color var(--dur-fast) var(--ease-out), + transform 0.1s ease; } .icon-btn:hover { background: var(--bg-chip); color: var(--text-primary); } +.icon-btn:active { + transform: scale(0.94); + background: rgba(255, 255, 255, 0.11); +} +.icon-btn.active { + background: var(--bg-chip); + color: #ffffff; +} /* Search */ .search { @@ -572,6 +581,27 @@ color: #ffffff; } +/* Retina-friendly controls: lighter surfaces and less chrome than the + Windows/DWM treatment, while preserving the same layout and interaction. */ +html[data-platform='darwin'] .filter-chip { + width: 26px; + height: 26px; + border: 0; + background: rgba(255, 255, 255, 0.045); + background-clip: border-box; + -webkit-background-clip: border-box; + color: rgba(255, 255, 255, 0.68); +} + +html[data-platform='darwin'] .filter-chip:not(.active):hover { + background: rgba(255, 255, 255, 0.095); + color: rgba(255, 255, 255, 0.94); +} + +html[data-platform='darwin'] .icon-btn { + border-radius: 8px; +} + /* Copy Indicator Sine Curve Morph Styles */ .copy-curve-container { pointer-events: none; @@ -584,4 +614,3 @@ font-family: inherit; font-feature-settings: "cv02", "cv03", "cv04", "cv11"; } - diff --git a/src/styles/settings.css b/src/styles/settings.css index 95bb00a..afea772 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -721,10 +721,9 @@ } .indicator-card.active { - background: #141414; + background: rgba(255, 255, 255, 0.055); box-shadow: none; - outline: 2px solid #ffffff; - outline-offset: 0; + outline: none; } .indicator-card-stage { @@ -1281,4 +1280,4 @@ .floating-update-pill:hover .floating-update-arrow { transform: translateY(1px); -} \ No newline at end of file +} diff --git a/tests/dropPayload.test.ts b/tests/dropPayload.test.ts new file mode 100644 index 0000000..f3efe38 --- /dev/null +++ b/tests/dropPayload.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { filePathsFromUriList, hasExternalDragPayload } from '../shared/dropPayload' + +describe('external drop payloads', () => { + it('recognizes Finder URI-list drags as external content', () => { + expect(hasExternalDragPayload(['text/uri-list'])).toBe(true) + expect(hasExternalDragPayload(['public.file-url'])).toBe(true) + expect(hasExternalDragPayload(['application/octet-stream'])).toBe(false) + }) + + it('decodes macOS Finder file URLs and ignores comments', () => { + expect(filePathsFromUriList( + '# Finder item\nfile:///Users/demo/Pictures/photo%20one.png\nhttps://example.com', + 'darwin' + )).toEqual(['/Users/demo/Pictures/photo one.png']) + }) + + it('supports multiple unique files in one drop', () => { + expect(filePathsFromUriList( + 'file:///tmp/one.png\r\nfile:///tmp/two.png\r\nfile:///tmp/one.png', + 'darwin' + )).toEqual(['/tmp/one.png', '/tmp/two.png']) + }) + + it('normalizes Windows drive-letter file URLs', () => { + expect(filePathsFromUriList('file:///C:/Users/demo/photo.png', 'win32')) + .toEqual(['C:\\Users\\demo\\photo.png']) + }) +}) diff --git a/tests/geometry.test.ts b/tests/geometry.test.ts index ed5e306..0e43574 100644 --- a/tests/geometry.test.ts +++ b/tests/geometry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { computeStickBounds, type DisplayInfo } from '../electron/main/geometry' +import { computeCollapsedWindowBounds, computeStickBounds, type DisplayInfo } from '../electron/main/geometry' function makeDisplay( id: number, x: number, y: number, w: number, h: number, @@ -11,6 +11,24 @@ function makeDisplay( const PRIMARY = makeDisplay(1, 0, 0, 1920, 1040, { isPrimary: true, scaleFactor: 1 }) const SECONDARY = makeDisplay(2, 1920, 0, 1920, 1040, { isPrimary: false, scaleFactor: 1 }) +describe('computeCollapsedWindowBounds', () => { + const full = { x: 100, y: 24, width: 384, height: 1056 } + + it('keeps the narrow target on the left edge', () => { + expect(computeCollapsedWindowBounds(full, 'left', 3)).toEqual({ x: 100, y: 24, width: 3, height: 1056 }) + }) + + it('keeps the narrow target on the right edge', () => { + expect(computeCollapsedWindowBounds(full, 'right', 3)).toEqual({ x: 481, y: 24, width: 3, height: 1056 }) + }) + + it('clamps invalid and oversized hot zones', () => { + expect(computeCollapsedWindowBounds(full, 'left', 0).width).toBe(1) + expect(computeCollapsedWindowBounds(full, 'left', Number.NaN).width).toBe(1) + expect(computeCollapsedWindowBounds(full, 'right', 999).width).toBe(384) + }) +}) + describe('computeStickBounds � original tests', () => { it('sticks to left edge of primary display', () => { const r = computeStickBounds({ position: 'left', displays: [PRIMARY], windowWidth: 384 }) @@ -122,4 +140,3 @@ describe('TV mirror regression � combined scenario', () => { expect(r.displayId).toBe(11) }) }) - diff --git a/tests/imageProtocol.test.ts b/tests/imageProtocol.test.ts index 32d80ef..e4f50cb 100644 --- a/tests/imageProtocol.test.ts +++ b/tests/imageProtocol.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { resolveStoredImage, thumbnailUrlForFile, thumbnailUrlForStoredImage } from '../electron/main/imageProtocol' +import { isMacFileListFormat } from '../electron/clipboard/formats' describe('resolveStoredImage', () => { let imagesDir: string @@ -55,3 +56,15 @@ describe('resolveStoredImage', () => { .toBe('edgelocal://thumb/file/C%3A%2FPictures%2Fimage%20one.png') }) }) + +describe('macOS clipboard file formats', () => { + it('recognizes Electron\'s normalized Finder URI-list format', () => { + expect(isMacFileListFormat('text/uri-list')).toBe(true) + }) + + it('keeps native Finder file-list format compatibility', () => { + expect(isMacFileListFormat('public.file-url')).toBe(true) + expect(isMacFileListFormat('NSFilenamesPboardType')).toBe(true) + expect(isMacFileListFormat('image/png')).toBe(false) + }) +}) diff --git a/tests/launchAtLoginFixes.test.ts b/tests/launchAtLoginFixes.test.ts index efcf7e3..d15b8bd 100644 --- a/tests/launchAtLoginFixes.test.ts +++ b/tests/launchAtLoginFixes.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const describeWindows = process.platform === 'win32' ? describe : describe.skip + const mocks = vi.hoisted(() => ({ isPackaged: true, exePath: 'C:\\Users\\Test User\\AppData\\Local\\Programs\\Edge-Drop\\Edge-Drop.exe', @@ -103,7 +105,7 @@ describe('GitHub Run-key health (quoted, current path, --hidden)', () => { }) }) -describe('raw registry read (reg query)', () => { +describeWindows('raw registry read (reg query)', () => { beforeEach(() => { delete process.env.APP_BUILD_TARGET mocks.isPackaged = true @@ -149,7 +151,7 @@ describe('raw registry read (reg query)', () => { }) }) -describe('reconcile never silently loses ON after update', () => { +describeWindows('reconcile never silently loses ON after update', () => { beforeEach(() => { delete process.env.APP_BUILD_TARGET mocks.isPackaged = true @@ -220,7 +222,7 @@ describe('reconcile never silently loses ON after update', () => { }) }) -describe('Store detection is redundant (env + package + path)', () => { +describeWindows('Store detection is redundant (env + package + path)', () => { beforeEach(() => { delete process.env.APP_BUILD_TARGET delete (process as unknown as { windowsStore?: boolean }).windowsStore diff --git a/tests/macosPort.test.ts b/tests/macosPort.test.ts new file mode 100644 index 0000000..592ee1b --- /dev/null +++ b/tests/macosPort.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +const root = join(__dirname, '..') +const read = (relativePath: string) => readFileSync(join(root, relativePath), 'utf8') + +describe('macOS port contracts', () => { + const pkg = JSON.parse(read('package.json')) + + it('provides dedicated development, packaging, and release commands', () => { + expect(pkg.scripts['dev:mac']).toContain('build:mac-helper') + expect(pkg.scripts['build:mac']).toContain('electron-builder --mac') + expect(pkg.scripts['build:mac:release']).toContain('electron-builder --mac') + }) + + it('packages a menu-bar app and only the macOS native helper', () => { + expect(pkg.build.mac.extendInfo.LSUIElement).toBe(true) + expect(pkg.build.mac.extraResources).toContainEqual({ + from: 'resources/macos/bin/EdgeDropMacHelper', + to: 'macos/EdgeDropMacHelper' + }) + expect(pkg.build.mac.extraResources).not.toContainEqual(expect.objectContaining({ + from: expect.stringContaining('EdgeDropStartup.exe') + })) + expect(existsSync(join(root, pkg.build.mac.icon))).toBe(true) + }) + + it('uses native pasteboard, Command+V, and fullscreen APIs', () => { + const source = read('resources/macos/EdgeDropMacHelper.swift') + expect(source).toContain('NSPasteboard.general') + expect(source).toContain('.urlReadingFileURLsOnly') + expect(source).toContain('pasteboard.writeObjects') + expect(source).toContain('down.flags = .maskCommand') + expect(source).toContain('CGWindowListCopyWindowInfo') + }) + + it('keeps the panel across Spaces and out of Mission Control', () => { + const source = read('electron/main/window.ts') + expect(source).toContain('setVisibleOnAllWorkspaces(true') + expect(source).toContain('visibleOnFullScreen: true') + expect(source).toContain('setHiddenInMissionControl(true)') + }) + + it('allows packaged data fonts under the renderer CSP', () => { + expect(read('index.html')).toContain("font-src 'self' data:") + }) +}) diff --git a/tests/storeLoginApply.test.ts b/tests/storeLoginApply.test.ts index 2b954eb..44dc933 100644 --- a/tests/storeLoginApply.test.ts +++ b/tests/storeLoginApply.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const describeWindows = process.platform === 'win32' ? describe : describe.skip + const store = vi.hoisted(() => ({ get: vi.fn(async () => 2 as number | null), enable: vi.fn(async () => 2 as number | null), @@ -58,7 +60,7 @@ vi.mock('node:child_process', async (importOriginal) => { import { app } from 'electron' import { applyLaunchAtLogin, reconcileLaunchAtLoginOnStartup, refreshLaunchAtLoginFromOs } from '../electron/main/loginItems' -describe('Store applyLaunchAtLogin uses only enable / disable / getStatus', () => { +describeWindows('Store applyLaunchAtLogin uses only enable / disable / getStatus', () => { beforeEach(() => { process.env.APP_BUILD_TARGET = 'store' mocks.isPackaged = true diff --git a/tests/storeLoginItem.test.ts b/tests/storeLoginItem.test.ts index d5658d3..f5e8ddd 100644 --- a/tests/storeLoginItem.test.ts +++ b/tests/storeLoginItem.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const describeWindows = process.platform === 'win32' ? describe : describe.skip + const mocks = vi.hoisted(() => ({ isPackaged: true, setLoginItemSettings: vi.fn(), @@ -95,7 +97,7 @@ function parseWindowsRunCommand(cmd: string): { exe: string; args: string[] } { return { exe: t.slice(0, space), args: t.slice(space + 1).split(/\s+/) } } -describe('GitHub exe launch-at-login (orphan Run keys)', () => { +describeWindows('GitHub exe launch-at-login (orphan Run keys)', () => { beforeEach(() => { delete process.env.APP_BUILD_TARGET mocks.isPackaged = true @@ -285,7 +287,7 @@ describe('GitHub exe launch-at-login (orphan Run keys)', () => { }) }) -describe('GitHub Run-key quoting and update heal', () => { +describeWindows('GitHub Run-key quoting and update heal', () => { const spacedExe = 'C:\\Users\\Renato Souza\\AppData\\Local\\Programs\\Edge-Drop\\Edge-Drop.exe' const plainExe = 'C:\\Users\\yadav\\AppData\\Local\\Programs\\Edge-Drop\\Edge-Drop.exe' const programFilesExe = 'C:\\Program Files\\Edge-Drop\\Edge-Drop.exe' diff --git a/tests/storePackaging.test.ts b/tests/storePackaging.test.ts index 31079f0..4c67e2e 100644 --- a/tests/storePackaging.test.ts +++ b/tests/storePackaging.test.ts @@ -18,6 +18,8 @@ describe('GitHub vs Store packaging contracts (on-disk, not assumed)', () => { asarUnpack: string[] extraResources?: Array<{ from: string; to: string }> files?: string[] + win: { extraResources?: Array<{ from: string; to: string }> } + mac: { extraResources?: Array<{ from: string; to: string }>; extendInfo?: { LSUIElement?: boolean } } appx: { identityName: string publisher: string @@ -64,7 +66,10 @@ describe('GitHub vs Store packaging contracts (on-disk, not assumed)', () => { it('ships the windowless StartupTask helper outside the asar', () => { expect(pkg.build.files).toEqual(expect.arrayContaining(['!resources/startup/**/*.exe'])) - expect(pkg.build.extraResources).toEqual(expect.arrayContaining([ + expect(pkg.build.win.extraResources).toEqual(expect.arrayContaining([ + { from: 'resources/startup/EdgeDropStartup.exe', to: 'startup/EdgeDropStartup.exe' } + ])) + expect(pkg.build.mac.extraResources).not.toEqual(expect.arrayContaining([ { from: 'resources/startup/EdgeDropStartup.exe', to: 'startup/EdgeDropStartup.exe' } ])) expect(existsSync(join(root, 'resources/startup/EdgeDropStartup.exe'))).toBe(true) @@ -177,7 +182,7 @@ describe('GitHub vs Store packaging contracts (on-disk, not assumed)', () => { it('main process only sets the custom AUMID on the GitHub build', () => { const src = read('electron/main/index.ts') - expect(src).toMatch(/if\s*\(\s*!isStoreBuild\(\)\s*\)\s*\{[\s\S]*setAppUserModelId\('com\.edgedrop\.app'\)/) + expect(src).toMatch(/if\s*\(\s*process\.platform\s*===\s*'win32'\s*&&\s*!isStoreBuild\(\)\s*\)\s*\{[\s\S]*setAppUserModelId\('com\.edgedrop\.app'\)/) expect(src).not.toMatch(/setAppUserModelId\([\s\S]*isStoreBuild\(\)/) }) diff --git a/tests/storePaths.test.ts b/tests/storePaths.test.ts index 606157c..5881acc 100644 --- a/tests/storePaths.test.ts +++ b/tests/storePaths.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const describeWindows = process.platform === 'win32' ? describe : describe.skip import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -43,7 +45,7 @@ function sha10(filePath: string): string { return createHash('sha1').update(filePath).digest('hex').slice(0, 10) } -describe('Store vs exe filesystem interop', () => { +describeWindows('Store vs exe filesystem interop', () => { beforeEach(() => { delete process.env.APP_BUILD_TARGET fsRoots.home = join(tmpdir(), `ed-home-${Date.now()}-${Math.random().toString(16).slice(2)}`) diff --git a/tests/trayTheme.test.ts b/tests/trayTheme.test.ts index 242c5f0..7adfbec 100644 --- a/tests/trayTheme.test.ts +++ b/tests/trayTheme.test.ts @@ -6,6 +6,8 @@ const mocks = vi.hoisted(() => ({ shouldUseDarkColors: true })) +const itWindows = process.platform === 'win32' ? it : it.skip + vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal() return { @@ -91,7 +93,7 @@ describe('Tray Icon Theme Adaptation (Issue #64)', () => { expect(darkBuf.subarray(0, 8)).toEqual(pngMagic) }) - it('detects Light taskbar when SystemUsesLightTheme is 0x1', () => { + itWindows('detects Light taskbar when SystemUsesLightTheme is 0x1', () => { mocks.execFileSync.mockReturnValue( 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\r\n SystemUsesLightTheme REG_DWORD 0x1\r\n' )