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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/macos.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules
out
dist
resources/macos/build
resources/macos/bin
*.log
drag_debug.txt
clipboard_debug.log
Expand All @@ -21,4 +23,3 @@ icons-showcase.svg
scripts/generate_showcase.mjs
electron-clipboard-search-implementation/
scratch/verify-*.cjs

46 changes: 46 additions & 0 deletions MACOS.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<img src=".github/readme/Logo.gif" alt="Edge-Drop Logo" width="220" style="max-width: 100%; height: auto;" />
</p>

> **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.

<h1 align="center">Edge-Drop</h1>

<p align="center">
Expand Down Expand Up @@ -37,7 +39,7 @@
<a href="https://github.com/Deepender25/Edge-Drop/releases"><img alt="Release" src="https://img.shields.io/github/v/release/Deepender25/Edge-Drop?style=flat-square&labelColor=23272e&color=d2f4e8" /></a>
<img src="https://img.shields.io/badge/tests-364%20passing-8ca77b?style=flat-square&logo=vitest&logoColor=white&labelColor=23272e" alt="Tests" />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/github/license/Deepender25/Edge-Drop?style=flat-square&labelColor=23272e&color=ffe6b3" /></a>
<img src="https://img.shields.io/badge/platform-Windows%2010%20%2F%2011-93a4fc?style=flat-square&logo=windows&logoColor=white&labelColor=23272e" alt="Platform" />
<img src="https://img.shields.io/badge/platform-Windows%20%2B%20macOS-93a4fc?style=flat-square&logo=apple&logoColor=white&labelColor=23272e" alt="Platform" />
</p>

<p align="center">
Expand Down
49 changes: 48 additions & 1 deletion electron/clipboard/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,6 +66,14 @@ export const CF_FILE_LIST = 'FileNameW'
*/
async function readFileListAsync(): Promise<string[] | null> {
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)
Expand Down Expand Up @@ -97,6 +118,24 @@ async function readFileListAsync(): Promise<string[] | null> {
/** 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')
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion electron/main/fullscreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -70,6 +71,7 @@ if (process.platform === 'win32') {
let isFullscreenActiveCache = false
let checkTimer: ReturnType<typeof setInterval> | null = null
let onFullscreenDetectedFn: (() => void) | null = null
let macCheckInFlight = false

export function registerFullscreenActiveListener(fn: () => void): void {
onFullscreenDetectedFn = fn
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions electron/main/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
8 changes: 7 additions & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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')
}

Expand Down
31 changes: 23 additions & 8 deletions electron/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -71,6 +73,9 @@ function simulatePaste(): void {
})
})
}
if (process.platform === 'darwin') {
void simulateMacPaste()
}
}

/**
Expand Down Expand Up @@ -107,6 +112,9 @@ async function writeFileListToClipboard(rawPaths: string[]): Promise<boolean> {
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'))
Expand Down Expand Up @@ -141,6 +149,9 @@ export async function writeImageToClipboard(imagePath: string | null): Promise<b
* our friendly filename. Atomic multi-format write via PowerShell DataObject.
*/
async function writeImageWithNamedFile(imagePath: string, namedPath: string): Promise<boolean> {
if (process.platform === 'darwin') {
return writeMacClipboardImage(imagePath, [namedPath])
}
if (process.platform !== 'win32') return false
try {
const b64Img = Buffer.from(imagePath, 'utf8').toString('base64')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
Loading