diff --git a/Jot.xcodeproj/project.pbxproj b/Jot.xcodeproj/project.pbxproj index b9f86d6..fb6737f 100644 --- a/Jot.xcodeproj/project.pbxproj +++ b/Jot.xcodeproj/project.pbxproj @@ -414,7 +414,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.5.8; + MARKETING_VERSION = 2.8.1; PRODUCT_BUNDLE_IDENTIFIER = slf.PinStick; PRODUCT_NAME = PinStick; REGISTER_APP_GROUPS = YES; @@ -456,7 +456,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.5.8; + MARKETING_VERSION = 2.8.1; PRODUCT_BUNDLE_IDENTIFIER = slf.PinStick; PRODUCT_NAME = PinStick; REGISTER_APP_GROUPS = YES; @@ -479,7 +479,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 2.5.8; + MARKETING_VERSION = 2.8.1; PRODUCT_BUNDLE_IDENTIFIER = slf.PinStickTests; PRODUCT_NAME = PinStickTests; SDKROOT = auto; @@ -502,7 +502,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 2.5.8; + MARKETING_VERSION = 2.8.1; PRODUCT_BUNDLE_IDENTIFIER = slf.PinStickTests; PRODUCT_NAME = PinStickTests; SDKROOT = auto; @@ -524,7 +524,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 2.5.8; + MARKETING_VERSION = 2.8.1; PRODUCT_BUNDLE_IDENTIFIER = slf.PinStickUITests; PRODUCT_NAME = PinStickUITests; SDKROOT = auto; @@ -546,7 +546,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 2.5.8; + MARKETING_VERSION = 2.8.1; PRODUCT_BUNDLE_IDENTIFIER = slf.PinStickUITests; PRODUCT_NAME = PinStickUITests; SDKROOT = auto; diff --git a/MediaNoteSupport.swift b/MediaNoteSupport.swift new file mode 100644 index 0000000..c45e2c7 --- /dev/null +++ b/MediaNoteSupport.swift @@ -0,0 +1,161 @@ +import Foundation +import UniformTypeIdentifiers + +enum MediaKind: String, Codable { + case image + case video +} + +struct RemoteMediaItem: Identifiable, Equatable { + let id: String + let url: URL + let kind: MediaKind +} + +struct LocalMediaItem: Equatable { + let url: URL + let kind: MediaKind +} + +enum MediaNoteSupport { + static let noteStorageKey = "pinstick-note" + static let localMediaBookmarkKey = "pinstick-local-media-bookmark" + + private static let imageExtensions = ["avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"] + private static let videoExtensions = ["m4v", "mov", "mp4", "ogg", "ogv", "webm"] + private static let trailingPunctuation = CharacterSet(charactersIn: "),.!?;:") + + static func classifyMediaURL(_ url: URL) -> MediaKind? { + let path = url.path.lowercased() + let ext = (path as NSString).pathExtension + if imageExtensions.contains(ext) { + return .image + } + if videoExtensions.contains(ext) { + return .video + } + return nil + } + + static func classifyLocalFile(url: URL) -> MediaKind? { + if let type = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { + if type.conforms(to: .image) { + return .image + } + if type.conforms(to: .movie) || type.conforms(to: .video) { + return .video + } + } + return classifyMediaURL(url) + } + + static func parseRemoteMedia(from noteText: String) -> [RemoteMediaItem] { + var items: [RemoteMediaItem] = [] + var seen = Set() + + let markdownPattern = #"!\[[^\]]*\]\((https?://[^)\s]+)\)"# + if let regex = try? NSRegularExpression(pattern: markdownPattern) { + let range = NSRange(noteText.startIndex.. 1, + let urlRange = Range(match.range(at: 1), in: noteText) else { return } + appendRemoteURL(String(noteText[urlRange]), to: &items, seen: &seen) + } + } + + let urlPattern = #"https?://[^\s<>"')\]]+"# + if let regex = try? NSRegularExpression(pattern: urlPattern) { + let range = NSRange(noteText.startIndex.. String { + let urlString = url.absoluteString + var updated = noteText + + let markdownPattern = #"!\[[^\]]*\]\(\#(NSRegularExpression.escapedPattern(for: urlString))\)\s*"# + if let regex = try? NSRegularExpression(pattern: markdownPattern) { + updated = regex.stringByReplacingMatches( + in: updated, + range: NSRange(updated.startIndex.. LocalMediaItem? { + guard let data = UserDefaults.standard.data(forKey: localMediaBookmarkKey) else { + return nil + } + + var isStale = false + do { + let url = try URL( + resolvingBookmarkData: data, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + if isStale { + saveLocalMediaBookmark(for: url) + } + guard url.startAccessingSecurityScopedResource() else { return nil } + guard let kind = classifyLocalFile(url: url) else { + url.stopAccessingSecurityScopedResource() + return nil + } + return LocalMediaItem(url: url, kind: kind) + } catch { + NSLog("Failed to resolve media bookmark: \(error.localizedDescription)") + return nil + } + } + + static func clearLocalMedia() { + if let existing = loadLocalMedia() { + existing.url.stopAccessingSecurityScopedResource() + } + UserDefaults.standard.removeObject(forKey: localMediaBookmarkKey) + } + + private static func appendRemoteURL( + _ rawURL: String, + to items: inout [RemoteMediaItem], + seen: inout Set + ) { + let trimmed = rawURL.trimmingCharacters(in: trailingPunctuation) + guard let url = URL(string: trimmed), + let kind = classifyMediaURL(url), + seen.insert(trimmed).inserted else { return } + items.append(RemoteMediaItem(id: trimmed, url: url, kind: kind)) + } +} diff --git a/OverlayWindowSupport.swift b/OverlayWindowSupport.swift new file mode 100644 index 0000000..9a966a5 --- /dev/null +++ b/OverlayWindowSupport.swift @@ -0,0 +1,83 @@ +import AppKit +import SwiftUI + +enum OverlayWindowSupport { + static let opacityStorageKey = "pinstick-overlay-opacity" + + static func defaultOpacity() -> Double { + let stored = UserDefaults.standard.double(forKey: opacityStorageKey) + if stored > 0 { + return min(1, max(0.4, stored)) + } + return 0.7 + } + + static func saveOpacity(_ value: Double) { + let clamped = min(1, max(0.4, value)) + UserDefaults.standard.set(clamped, forKey: opacityStorageKey) + } + + static func applyOverlay(on window: NSWindow, opacity: Double) { + window.level = .floating + window.isOpaque = false + window.backgroundColor = .clear + window.alphaValue = opacity + window.ignoresMouseEvents = true + } + + static func restoreNormalWindow(_ window: NSWindow, isPinned: Bool) { + window.ignoresMouseEvents = false + window.isOpaque = true + window.backgroundColor = nil + window.alphaValue = 1 + window.level = isPinned ? .floating : .normal + } + + static func frameInScreenCoordinates(for view: NSView) -> CGRect { + let bounds = view.bounds + let origin = view.convert(NSPoint(x: bounds.minX, y: bounds.minY), to: nil) + let size = view.convert(NSPoint(x: bounds.maxX, y: bounds.maxY), to: nil) + let windowRect = NSRect( + x: min(origin.x, size.x), + y: min(origin.y, size.y), + width: abs(size.x - origin.x), + height: abs(size.y - origin.y) + ) + return view.window?.convertToScreen(windowRect) ?? .zero + } + + static func isMouseOver(rect: CGRect) -> Bool { + guard !rect.isEmpty else { return false } + let mouse = NSEvent.mouseLocation + return rect.contains(mouse) + } +} + +/// Polls mouse position without Accessibility permissions (unlike global event monitors). +final class OverlayMouseMonitor { + private var timer: Timer? + private weak var window: NSWindow? + private let hitTest: () -> Bool + + init(window: NSWindow, hitTest: @escaping () -> Bool) { + self.window = window + self.hitTest = hitTest + } + + func start() { + stop() + timer = Timer.scheduledTimer(withTimeInterval: 0.06, repeats: true) { [weak self] _ in + guard let self, let window = self.window else { return } + window.ignoresMouseEvents = !self.hitTest() + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + deinit { + stop() + } +} diff --git a/PinStickApp.swift b/PinStickApp.swift index 8a64e56..4d23686 100644 --- a/PinStickApp.swift +++ b/PinStickApp.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import AVKit @main struct PinStickApp: App { @@ -56,7 +57,7 @@ struct ContentViewWrapper: NSViewControllerRepresentable { DispatchQueue.main.async { if let window = controller.view.window { window.isDocumentEdited = false - window.level = .normal // default level + window.level = .normal } } return controller @@ -66,49 +67,443 @@ struct ContentViewWrapper: NSViewControllerRepresentable { } struct ContentView: View { - @State private var text: String = "" { - didSet { - if let window = NSApplication.shared.windows.first { - window.isDocumentEdited = !text.isEmpty - } - } + @AppStorage(MediaNoteSupport.noteStorageKey) private var text: String = "" + @AppStorage(OverlayWindowSupport.opacityStorageKey) private var overlayOpacity: Double = 0.7 + @State private var localMedia: LocalMediaItem? + @State private var isPinned: Bool = false + @State private var isOverlay = false + @State private var savedPinBeforeOverlay = false + @State private var overlayButtonScreenFrame: CGRect = .zero + @State private var headerToolbarScreenFrame: CGRect = .zero + @State private var overlayMouseMonitor: OverlayMouseMonitor? + @State private var overlayNoticeMessage: String? + + private var remoteMedia: [RemoteMediaItem] { + MediaNoteSupport.parseRemoteMedia(from: text) } - @State private var isPinned: Bool = false + private var hasMedia: Bool { + localMedia != nil || !remoteMedia.isEmpty + } + + private var isEdited: Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || hasMedia + } var body: some View { VStack(spacing: 0) { - HStack { + HStack(spacing: 4) { Button(action: togglePin) { Image(systemName: isPinned ? "pin.slash" : "pin") .help(isPinned ? "Unpin Window" : "Pin Window") .font(.system(size: 12)) } .buttonStyle(PlainButtonStyle()) - .padding(.horizontal, 8) - .padding(.vertical, 4) + .disabled(isOverlay) + + OverlayToolbarButton( + isActive: isOverlay, + screenFrame: $overlayButtonScreenFrame, + action: toggleOverlay, + onRightClick: showOverlayMenu + ) Spacer() } - .background(Color(NSColor.windowBackgroundColor)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + HeaderToolbarFrameTracker(screenFrame: $headerToolbarScreenFrame) + ) + .background(Color(NSColor.windowBackgroundColor).opacity(isOverlay ? 0.92 : 1)) + + if let overlayNoticeMessage { + Text(overlayNoticeMessage) + .font(.system(size: 12)) + .foregroundStyle(Color(red: 0.35, green: 0.29, blue: 0.07)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(red: 1.0, green: 0.97, blue: 0.9)) + } Divider() - TextEditor(text: $text) - .padding(8) - .font(.system(size: 16, design: .monospaced)) + if hasMedia { + MediaStageView( + localMedia: localMedia, + remoteMedia: remoteMedia, + onRemoveLocal: removeLocalMedia, + onRemoveRemote: removeRemoteMedia + ) + .allowsHitTesting(!isOverlay) + } else { + ZStack(alignment: .topLeading) { + TextEditor(text: $text) + .padding(8) + .font(.system(size: 16, design: .monospaced)) + .disabled(isOverlay) + + if text.isEmpty { + Text("Type your notes here… or double-click to add media.") + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 16) + .allowsHitTesting(false) + } + } + .contentShape(Rectangle()) + .allowsHitTesting(!isOverlay) + .onTapGesture(count: 2) { + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + openMediaFilePicker() + } + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: text) { oldValue, newValue in - if let window = NSApplication.shared.windows.first { - window.isDocumentEdited = !newValue.isEmpty + .onAppear { + reloadLocalMedia() + updateDocumentEdited() + } + .onChange(of: text) { _, _ in + updateDocumentEdited() + } + .onChange(of: localMedia) { _, _ in + updateDocumentEdited() + } + .onChange(of: remoteMedia.count) { _, _ in + updateDocumentEdited() + } + .onChange(of: overlayOpacity) { _, newValue in + OverlayWindowSupport.saveOpacity(newValue) + if isOverlay { + applyOverlayToWindow() } } } + private func reloadLocalMedia() { + if let loaded = MediaNoteSupport.loadLocalMedia() { + localMedia = loaded + } + } + + private func updateDocumentEdited() { + if let window = NSApplication.shared.windows.first { + window.isDocumentEdited = isEdited + } + } + + private func removeLocalMedia() { + if let localMedia { + localMedia.url.stopAccessingSecurityScopedResource() + } + MediaNoteSupport.clearLocalMedia() + self.localMedia = nil + } + + private func removeRemoteMedia(_ item: RemoteMediaItem) { + text = MediaNoteSupport.removeRemoteMedia(url: item.url, from: text) + } + + private func openMediaFilePicker() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.image, .movie, .video] + + guard panel.runModal() == .OK, let url = panel.url else { return } + guard let kind = MediaNoteSupport.classifyLocalFile(url: url) else { return } + + if let existing = localMedia { + existing.url.stopAccessingSecurityScopedResource() + } + + MediaNoteSupport.saveLocalMediaBookmark(for: url) + _ = url.startAccessingSecurityScopedResource() + localMedia = LocalMediaItem(url: url, kind: kind) + } + private func togglePin() { - guard let window = NSApplication.shared.windows.first else { return } + guard !isOverlay, let window = NSApplication.shared.windows.first else { return } isPinned.toggle() window.level = isPinned ? .floating : .normal } + + private func applyOverlayToWindow() { + guard let window = NSApplication.shared.windows.first else { return } + OverlayWindowSupport.applyOverlay(on: window, opacity: overlayOpacity) + } + + private func isCursorOverHeaderControls() -> Bool { + if OverlayWindowSupport.isMouseOver(rect: headerToolbarScreenFrame) { + return true + } + if OverlayWindowSupport.isMouseOver(rect: overlayButtonScreenFrame) { + return true + } + return false + } + + private func toggleOverlay() { + guard let window = NSApplication.shared.windows.first else { return } + + if isOverlay { + isOverlay = false + overlayNoticeMessage = nil + overlayMouseMonitor?.stop() + overlayMouseMonitor = nil + OverlayWindowSupport.restoreNormalWindow(window, isPinned: savedPinBeforeOverlay) + isPinned = savedPinBeforeOverlay + } else { + savedPinBeforeOverlay = isPinned + isOverlay = true + overlayNoticeMessage = nil + applyOverlayToWindow() + window.ignoresMouseEvents = false + overlayMouseMonitor = OverlayMouseMonitor(window: window) { + isCursorOverHeaderControls() + } + overlayMouseMonitor?.start() + } + } + + private func showOverlayMenu() { + let menu = NSMenu() + let opacityMenu = NSMenu(title: "Opacity") + for (label, value) in [("40%", 0.4), ("55%", 0.55), ("70%", 0.7), ("85%", 0.85), ("100%", 1.0)] { + let item = NSMenuItem(title: label, action: #selector(OverlayMenuHandler.setOpacity(_:)), keyEquivalent: "") + item.target = OverlayMenuHandler.shared + item.representedObject = value + item.state = abs(overlayOpacity - value) < 0.01 ? .on : .off + opacityMenu.addItem(item) + } + let opacityItem = NSMenuItem(title: "Opacity", action: nil, keyEquivalent: "") + opacityItem.submenu = opacityMenu + menu.addItem(opacityItem) + menu.addItem(.separator()) + let exitItem = NSMenuItem(title: "Exit overlay", action: #selector(OverlayMenuHandler.exitOverlay(_:)), keyEquivalent: "") + exitItem.target = OverlayMenuHandler.shared + menu.addItem(exitItem) + + OverlayMenuHandler.shared.onSetOpacity = { value in + overlayOpacity = value + } + OverlayMenuHandler.shared.onExitOverlay = { + if isOverlay { + toggleOverlay() + } + } + + if let event = NSApp.currentEvent { + NSMenu.popUpContextMenu(menu, with: event, for: event.window?.contentView ?? NSView()) + } + } +} + +private struct HeaderToolbarFrameTracker: NSViewRepresentable { + @Binding var screenFrame: CGRect + + func makeNSView(context: Context) -> HeaderToolbarFrameTrackerView { + HeaderToolbarFrameTrackerView() + } + + func updateNSView(_ nsView: HeaderToolbarFrameTrackerView, context: Context) { + nsView.onFrameChange = { screenFrame = $0 } + DispatchQueue.main.async { + screenFrame = OverlayWindowSupport.frameInScreenCoordinates(for: nsView) + } + } +} + +private final class HeaderToolbarFrameTrackerView: NSView { + var onFrameChange: ((CGRect) -> Void)? + + override func layout() { + super.layout() + onFrameChange?(OverlayWindowSupport.frameInScreenCoordinates(for: self)) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onFrameChange?(OverlayWindowSupport.frameInScreenCoordinates(for: self)) + } +} + +private final class OverlayMenuHandler: NSObject { + static let shared = OverlayMenuHandler() + var onSetOpacity: ((Double) -> Void)? + var onExitOverlay: (() -> Void)? + + @objc func setOpacity(_ sender: NSMenuItem) { + if let value = sender.representedObject as? Double { + onSetOpacity?(value) + } + } + + @objc func exitOverlay(_ sender: NSMenuItem) { + onExitOverlay?() + } +} + +struct OverlayToolbarButton: NSViewRepresentable { + let isActive: Bool + @Binding var screenFrame: CGRect + let action: () -> Void + let onRightClick: () -> Void + + func makeNSView(context: Context) -> OverlayToolbarButtonView { + let view = OverlayToolbarButtonView() + view.onClick = action + view.onRightClick = onRightClick + return view + } + + func updateNSView(_ nsView: OverlayToolbarButtonView, context: Context) { + nsView.onClick = action + nsView.onRightClick = onRightClick + nsView.isActive = isActive + nsView.updateScreenFrame = { screenFrame = $0 } + nsView.refreshAppearance() + DispatchQueue.main.async { + screenFrame = OverlayWindowSupport.frameInScreenCoordinates(for: nsView) + } + } +} + +final class OverlayToolbarButtonView: NSView { + var onClick: (() -> Void)? + var onRightClick: (() -> Void)? + var updateScreenFrame: ((CGRect) -> Void)? + var isActive = false + + private let button = NSButton(title: "🪟", target: nil, action: nil) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + button.bezelStyle = .inline + button.isBordered = false + button.font = NSFont.systemFont(ofSize: 14) + button.target = self + button.action = #selector(handleClick) + addSubview(button) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + button.frame = bounds + } + + override func rightMouseDown(with event: NSEvent) { + onRightClick?() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateScreenFrame?(OverlayWindowSupport.frameInScreenCoordinates(for: self)) + } + + func refreshAppearance() { + button.contentTintColor = isActive ? NSColor.systemBlue : NSColor.labelColor + toolTip = isActive ? "Exit overlay mode" : "Overlay mode" + } + + @objc private func handleClick() { + onClick?() + } +} + +struct MediaStageView: View { + let localMedia: LocalMediaItem? + let remoteMedia: [RemoteMediaItem] + let onRemoveLocal: () -> Void + let onRemoveRemote: (RemoteMediaItem) -> Void + + var body: some View { + ScrollView { + VStack(spacing: 8) { + if let localMedia { + MediaEmbedView(kind: localMedia.kind, url: localMedia.url) + .onTapGesture(count: 2) { + onRemoveLocal() + } + } + + ForEach(remoteMedia) { item in + MediaEmbedView(kind: item.kind, url: item.url) + .onTapGesture(count: 2) { + onRemoveRemote(item) + } + } + } + .padding(8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } +} + +struct MediaEmbedView: View { + let kind: MediaKind + let url: URL + + @State private var player: AVPlayer? + + var body: some View { + Group { + switch kind { + case .image: + if url.isFileURL { + if let image = NSImage(contentsOf: url) { + Image(nsImage: image) + .resizable() + .scaledToFit() + } else { + mediaError("Unable to load this image.") + } + } else { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFit() + case .failure: + mediaError("Unable to load this image.") + default: + ProgressView() + } + } + } + case .video: + if let player { + VideoPlayer(player: player) + .aspectRatio(16 / 9, contentMode: .fit) + } + } + } + .frame(maxWidth: .infinity, minHeight: 120, maxHeight: .infinity) + .help("Double-click to remove") + .onAppear { + if case .video = kind, player == nil { + player = AVPlayer(url: url) + } + } + .onDisappear { + player?.pause() + player = nil + } + } + + @ViewBuilder + private func mediaError(_ message: String) -> some View { + Text(message) + .foregroundStyle(.red) + .padding(8) + } } diff --git a/README.md b/README.md index 5961bc2..1cbad6b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ ## Cross-platform (Windows/Linux/macOS) — Tauri preview -A lightweight Tauri app lives under `cross-platform/` with the same core features: note editor with local persistence and pin/unpin window via `always_on_top`. +A lightweight Tauri app lives under `cross-platform/` with the same core features: note editor with local persistence, pin/unpin window via `always_on_top`, and **overlay mode** (always-on-top + transparency + click-through except the header toolbar). + +**Overlay on Linux:** Full desktop click-through works on **X11**. On **Wayland**, transparency and toolbar controls (exit overlay, opacity menu) still work, but click-through to the desktop is not available—the app shows an in-window notice and keeps the header interactive. ### Build locally diff --git a/cross-platform/package-lock.json b/cross-platform/package-lock.json index 6e8b303..6e009a6 100644 --- a/cross-platform/package-lock.json +++ b/cross-platform/package-lock.json @@ -1,12 +1,12 @@ { "name": "pinstick-cross", - "version": "2.8.0", + "version": "2.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pinstick-cross", - "version": "2.8.0", + "version": "2.8.1", "devDependencies": { "@tauri-apps/cli": "1.5.10" } diff --git a/cross-platform/package.json b/cross-platform/package.json index ede4d1a..0cd65f8 100644 --- a/cross-platform/package.json +++ b/cross-platform/package.json @@ -1,6 +1,6 @@ { "name": "pinstick-cross", - "version": "2.8.0", + "version": "2.8.1", "private": true, "type": "module", "scripts": { diff --git a/cross-platform/src-tauri/Cargo.lock b/cross-platform/src-tauri/Cargo.lock index b51a6e1..3527511 100644 --- a/cross-platform/src-tauri/Cargo.lock +++ b/cross-platform/src-tauri/Cargo.lock @@ -2056,7 +2056,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pinstick" -version = "2.8.0" +version = "2.8.1" dependencies = [ "cocoa 0.25.0", "raw-window-handle", diff --git a/cross-platform/src-tauri/Cargo.toml b/cross-platform/src-tauri/Cargo.toml index 0e06faa..4575f35 100644 --- a/cross-platform/src-tauri/Cargo.toml +++ b/cross-platform/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinstick" -version = "2.8.0" +version = "2.8.1" description = "PinStick cross-platform note pinning app" authors = ["SillyLittleTech"] edition = "2021" diff --git a/cross-platform/src-tauri/src/main.rs b/cross-platform/src-tauri/src/main.rs index f4c4169..373756b 100644 --- a/cross-platform/src-tauri/src/main.rs +++ b/cross-platform/src-tauri/src/main.rs @@ -186,22 +186,42 @@ fn get_cursor_position_impl(window: &tauri::Window) -> Result<(f64, f64), String } } +#[cfg(target_os = "linux")] +fn linux_x_display() -> Result<*mut x11::xlib::Display, String> { + use std::cell::RefCell; + use x11::xlib; + + thread_local! { + static DISPLAY: RefCell> = RefCell::new(None); + } + + DISPLAY.with(|cell| { + let mut guard = cell.borrow_mut(); + if guard.is_none() { + let display = unsafe { xlib::XOpenDisplay(std::ptr::null()) }; + if display.is_null() { + return Err("XOpenDisplay failed".into()); + } + *guard = Some(display); + } + Ok(*guard.as_ref().unwrap()) + }) +} + #[cfg(target_os = "linux")] fn get_cursor_position_impl(window: &tauri::Window) -> Result<(f64, f64), String> { if std::env::var_os("WAYLAND_DISPLAY").is_some() { return Err("wayland_cursor_unavailable".into()); } - use raw_window_handle::{HasRawWindowHandle, RawWindowHandle}; // trait for raw_window_handle() + use raw_window_handle::{HasRawWindowHandle, RawWindowHandle}; use x11::xlib; - let handle = window - .raw_window_handle() - .map_err(|e| format!("raw_window_handle failed: {e}"))?; + let handle = window.raw_window_handle(); match handle { RawWindowHandle::Xlib(xlib_handle) => unsafe { - let display = xlib_handle.display as *mut xlib::Display; + let display = linux_x_display()?; let mut root_return: xlib::Window = 0; let mut child_return: xlib::Window = 0; let mut root_x: i32 = 0; diff --git a/cross-platform/src-tauri/tauri.conf.json b/cross-platform/src-tauri/tauri.conf.json index d417f76..7e03c10 100644 --- a/cross-platform/src-tauri/tauri.conf.json +++ b/cross-platform/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "PinStick", - "version": "2.8.0" + "version": "2.8.1" }, "tauri": { "macOSPrivateApi": true,