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 @@
+> **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 @@
-
+
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'
)