From f1b19c46b162fcc9b9e056a754fdcd9e31cc9dc1 Mon Sep 17 00:00:00 2001 From: Dimitris Lolis Date: Wed, 9 Sep 2026 11:56:07 +0200 Subject: [PATCH 1/5] feat: add native macOS port --- .github/workflows/macos.yml | 31 +++++++ .gitignore | 3 +- MACOS.md | 46 ++++++++++ NOTICE | 14 +++ README.md | 4 +- electron/clipboard/formats.ts | 41 ++++++++- electron/main/fullscreen.ts | 15 +++- electron/main/index.ts | 8 +- electron/main/ipc.ts | 17 ++++ electron/main/loginItems.ts | 39 +++++++- electron/main/macos.ts | 81 +++++++++++++++++ electron/main/tray.ts | 11 +++ electron/main/updater.ts | 6 ++ electron/main/window.ts | 8 ++ electron/preload/index.ts | 2 + index.html | 2 +- package.json | 36 ++++++-- resources/icon-mac.png | Bin 0 -> 23553 bytes resources/macos/EdgeDropMacHelper.swift | 115 ++++++++++++++++++++++++ resources/macos/build-helper.sh | 20 +++++ shared/bridge.ts | 2 + src/components/HotkeyRecorder.tsx | 18 +++- tests/launchAtLoginFixes.test.ts | 8 +- tests/macosPort.test.ts | 48 ++++++++++ tests/storeLoginApply.test.ts | 4 +- tests/storeLoginItem.test.ts | 6 +- tests/storePackaging.test.ts | 9 +- tests/storePaths.test.ts | 4 +- tests/trayTheme.test.ts | 4 +- 29 files changed, 573 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/macos.yml create mode 100644 MACOS.md create mode 100644 NOTICE create mode 100644 electron/main/macos.ts create mode 100644 resources/icon-mac.png create mode 100644 resources/macos/EdgeDropMacHelper.swift create mode 100644 resources/macos/build-helper.sh create mode 100644 tests/macosPort.test.ts 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..f209a8a --- /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 /Users/dimitrislolis/Projects/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..0b46fa3 100644 --- a/electron/clipboard/formats.ts +++ b/electron/clipboard/formats.ts @@ -36,6 +36,7 @@ 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) @@ -53,6 +54,15 @@ export const CF_FILE_LIST = 'FileNameW' */ async function readFileListAsync(): Promise { try { + const advertisedFormats = clipboard.availableFormats().map((format) => format.toLowerCase()) + const hasMacFileUrls = advertisedFormats.includes('public.file-url') || + advertisedFormats.includes('nsfilenamespboardtype') + 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 +107,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 ['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 +144,12 @@ function readFileListFast(): string[] | null { */ export function clipboardHasFileNameW(): boolean { try { + if (process.platform === 'darwin') { + return clipboard.availableFormats().some((format) => { + const lower = format.toLowerCase() + return lower === 'public.file-url' || lower === 'nsfilenamespboardtype' + }) + } const buf = clipboard.readBuffer(CF_FILE_LIST) return !!(buf && buf.length >= 4) } catch { @@ -137,6 +171,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 +216,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') || + l === 'public.file-url' || l === 'nsfilenamespboardtype' }) } 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/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..11c0529 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -25,6 +25,7 @@ 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' export { isStoreBuild } @@ -71,6 +72,9 @@ function simulatePaste(): void { }) }) } + if (process.platform === 'darwin') { + void simulateMacPaste() + } } /** @@ -107,6 +111,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 +148,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') @@ -860,6 +870,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..a2e053d 100644 --- a/electron/main/window.ts +++ b/electron/main/window.ts @@ -554,6 +554,14 @@ export function createWindow(): BrowserWindow { // Start click-through with no forwarding — edge detection is done via cursor poll. 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) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f5f5eb0..8c891e2 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -176,6 +176,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 0000000000000000000000000000000000000000..c7bd10a61b4f06b501d2f7965ad46ee341531992 GIT binary patch literal 23553 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 Ye!|`rQ`0OnUx6(3boFyt=akR{0JAI}n*aa+ literal 0 HcmV?d00001 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/src/components/HotkeyRecorder.tsx b/src/components/HotkeyRecorder.tsx index b39ed4d..ced34d2 100644 --- a/src/components/HotkeyRecorder.tsx +++ b/src/components/HotkeyRecorder.tsx @@ -9,6 +9,8 @@ interface HotkeyRecorderProps { onChange: (nextHotkey: string) => 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/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' ) From 9787c61a51476aa27f6cb58c96b3dcac01f3527f Mon Sep 17 00:00:00 2001 From: Dimitris Lolis Date: Wed, 9 Sep 2026 13:49:14 +0200 Subject: [PATCH 2/5] fix(mac): recognize Finder URI-list clipboard items --- electron/clipboard/formats.ts | 24 ++++++++++++++++-------- tests/imageProtocol.test.ts | 13 +++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/electron/clipboard/formats.ts b/electron/clipboard/formats.ts index 0b46fa3..517206b 100644 --- a/electron/clipboard/formats.ts +++ b/electron/clipboard/formats.ts @@ -43,6 +43,18 @@ 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 @@ -55,8 +67,7 @@ export const CF_FILE_LIST = 'FileNameW' async function readFileListAsync(): Promise { try { const advertisedFormats = clipboard.availableFormats().map((format) => format.toLowerCase()) - const hasMacFileUrls = advertisedFormats.includes('public.file-url') || - advertisedFormats.includes('nsfilenamespboardtype') + const hasMacFileUrls = advertisedFormats.some(isMacFileListFormat) if (process.platform === 'darwin' && hasMacFileUrls) { const paths = await readMacClipboardFiles() const valid = filterValidPaths(paths ?? []) @@ -108,7 +119,7 @@ async function readFileListAsync(): Promise { function readFileListFast(): string[] | null { try { if (process.platform === 'darwin') { - for (const format of ['public.file-url', 'NSFilenamesPboardType']) { + 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, '') @@ -145,10 +156,7 @@ function readFileListFast(): string[] | null { export function clipboardHasFileNameW(): boolean { try { if (process.platform === 'darwin') { - return clipboard.availableFormats().some((format) => { - const lower = format.toLowerCase() - return lower === 'public.file-url' || lower === 'nsfilenamespboardtype' - }) + return clipboard.availableFormats().some(isMacFileListFormat) } const buf = clipboard.readBuffer(CF_FILE_LIST) return !!(buf && buf.length >= 4) @@ -217,7 +225,7 @@ function clipboardAdvertisesFileList(): boolean { return clipboard.availableFormats().some((f) => { const l = f.toLowerCase() return l === 'filenamew' || l === 'filename' || l.includes('shell idlist') || - l === 'public.file-url' || l === 'nsfilenamespboardtype' + isMacFileListFormat(l) }) } catch { return false 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) + }) +}) From 9cbb3613788a51dcfb11fd885e1a55c56e9b4a48 Mon Sep 17 00:00:00 2001 From: Dimitris Lolis Date: Wed, 9 Sep 2026 14:01:57 +0200 Subject: [PATCH 3/5] fix(mac): support Finder drops and normalize controls --- electron/main/ipc.ts | 14 +++++------ electron/preload/index.ts | 10 ++++++++ shared/dropPayload.ts | 51 +++++++++++++++++++++++++++++++++++++++ src/components/Header.tsx | 6 ----- src/components/Panel.tsx | 10 ++------ src/hooks/useEdgeHover.ts | 5 ++-- src/styles/global.css | 15 ++++++++++++ src/styles/panel.css | 24 ++++++++++++++---- tests/dropPayload.test.ts | 29 ++++++++++++++++++++++ 9 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 shared/dropPayload.ts create mode 100644 tests/dropPayload.test.ts diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index 11c0529..1717879 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -26,6 +26,7 @@ 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 } @@ -564,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) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 8c891e2..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) { 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..da7ae46 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -180,9 +180,6 @@ export function Header() { }} style={{ color: 'rgba(255, 255, 255, 0.75)', - background: 'transparent', - border: 'none', - boxShadow: 'none', flexShrink: 0, cursor: 'pointer', width: 32, @@ -238,9 +235,6 @@ export function Header() { }} style={{ color: '#ffffff', - background: 'transparent', - border: 'none', - boxShadow: 'none', flexShrink: 0, cursor: 'pointer', width: 32, 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/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..a5ac01f 100644 --- a/src/styles/panel.css +++ b/src/styles/panel.css @@ -142,15 +142,30 @@ place-items: center; border-radius: var(--radius-pill); color: var(--text-secondary); - background: transparent; - border: none; + background: rgba(255, 255, 255, 0.01); + border: 1px solid transparent; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0); transition: background var(--dur-fast) var(--ease-out), - color var(--dur-fast) var(--ease-out); + border-color var(--dur-fast) var(--ease-out), + box-shadow var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out), + transform 0.1s ease; } .icon-btn:hover { - background: var(--bg-chip); + background: rgba(255, 255, 255, 0.10); + border-color: rgba(255, 255, 255, 0.09); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.28); color: var(--text-primary); } +.icon-btn:active { + transform: scale(0.92); + background: rgba(255, 255, 255, 0.15); +} +.icon-btn.active { + background: rgba(255, 255, 255, 0.13); + border-color: rgba(255, 255, 255, 0.10); + color: #ffffff; +} /* Search */ .search { @@ -584,4 +599,3 @@ font-family: inherit; font-feature-settings: "cv02", "cv03", "cv04", "cv11"; } - 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']) + }) +}) From 63d65fe4ae48560a1c500f7dedd6dbd4a17d0735 Mon Sep 17 00:00:00 2001 From: Dimitris Lolis Date: Wed, 9 Sep 2026 17:23:28 +0200 Subject: [PATCH 4/5] fix(mac): accept Finder drops at the screen edge --- electron/main/geometry.ts | 22 ++++++ electron/main/window.ts | 99 +++++++++++++++---------- src/components/Header.tsx | 32 ++++---- src/components/IndicatorStyleFlyout.tsx | 6 +- src/main.tsx | 1 + src/styles/panel.css | 39 +++++++--- src/styles/settings.css | 7 +- tests/geometry.test.ts | 21 +++++- 8 files changed, 154 insertions(+), 73 deletions(-) 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/window.ts b/electron/main/window.ts index a2e053d..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,10 @@ 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, { @@ -656,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. @@ -675,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) }) @@ -703,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) @@ -842,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 @@ -865,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?.() } @@ -877,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/src/components/Header.tsx b/src/components/Header.tsx index da7ae46..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) }} > - + ) })} @@ -182,8 +186,8 @@ export function Header() { color: 'rgba(255, 255, 255, 0.75)', flexShrink: 0, cursor: 'pointer', - width: 32, - height: 32, + width: headerButtonSize, + height: headerButtonSize, display: 'grid', placeItems: 'center', position: 'relative', @@ -191,7 +195,7 @@ export function Header() { transition: 'all 0.15s ease' }} > - + {isChangelogUnread && ( - + - + {!settingsOpen && (updateInfo?.downloaded || ((settings.autoUpdates ?? true) && updateInfo?.hasUpdate)) && ( 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/panel.css b/src/styles/panel.css index a5ac01f..539d60c 100644 --- a/src/styles/panel.css +++ b/src/styles/panel.css @@ -142,28 +142,22 @@ place-items: center; border-radius: var(--radius-pill); color: var(--text-secondary); - background: rgba(255, 255, 255, 0.01); - border: 1px solid transparent; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0); + background: transparent; + border: none; transition: background var(--dur-fast) var(--ease-out), - border-color var(--dur-fast) var(--ease-out), - box-shadow var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out), transform 0.1s ease; } .icon-btn:hover { - background: rgba(255, 255, 255, 0.10); - border-color: rgba(255, 255, 255, 0.09); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.28); + background: var(--bg-chip); color: var(--text-primary); } .icon-btn:active { - transform: scale(0.92); - background: rgba(255, 255, 255, 0.15); + transform: scale(0.94); + background: rgba(255, 255, 255, 0.11); } .icon-btn.active { - background: rgba(255, 255, 255, 0.13); - border-color: rgba(255, 255, 255, 0.10); + background: var(--bg-chip); color: #ffffff; } @@ -587,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; 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/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) }) }) - From cbfee0ff5f3bfcf695df4443e28c90fb1cd915e8 Mon Sep 17 00:00:00 2001 From: Dimitris Lolis Date: Wed, 9 Sep 2026 20:40:09 +0200 Subject: [PATCH 5/5] Update path in MACOS.md for project directory Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- MACOS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MACOS.md b/MACOS.md index f209a8a..7064d52 100644 --- a/MACOS.md +++ b/MACOS.md @@ -11,7 +11,7 @@ Requirements: - Xcode Command Line Tools (`xcode-select --install`) ```bash -cd /Users/dimitrislolis/Projects/Edge-Drop +cd /path/to/Edge-Drop npm install npm run dev:mac ```