Skip to content

Commit c15cf1a

Browse files
committed
Fix system notifications on macOS and Windows
1 parent 8a0f81c commit c15cf1a

3 files changed

Lines changed: 58 additions & 64 deletions

File tree

scripts/build_local.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
3737
--windowed
3838
--icon "$ICON_ICNS"
3939
--osx-bundle-identifier "com.yibailiu.VideoMergingTool"
40+
--hidden-import Foundation
41+
--hidden-import AppKit
42+
--hidden-import UserNotifications
4043
--add-binary "$VENDOR_FFMPEG_DIR/ffmpeg:ffmpeg"
4144
--add-binary "$VENDOR_FFMPEG_DIR/ffprobe:ffmpeg"
4245
)

tests/test_gui_gpu.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pathlib import Path
66
from unittest.mock import Mock, patch
77

8-
from videomerge.gui import _build_merge_command, _detect_gui_ffmpeg_encoders
8+
from videomerge.gui import _build_merge_command, _detect_gui_ffmpeg_encoders, _windows_notification_script
99

1010

1111
class GuiGpuTests(unittest.TestCase):
@@ -38,6 +38,13 @@ def test_gui_command_writes_selected_file_list(self) -> None:
3838

3939
self.assertEqual(selected, ["/tmp/in/a.mp4", "/tmp/in/b.mp4"])
4040

41+
def test_windows_notification_script_uses_notify_icon_and_sound(self) -> None:
42+
script = _windows_notification_script("A&B's", "done", True)
43+
44+
self.assertIn("System.Windows.Forms.NotifyIcon", script)
45+
self.assertIn("[System.Media.SystemSounds]::Asterisk.Play()", script)
46+
self.assertIn("A&B''s", script)
47+
4148
def test_macos_gui_encoder_detection_retries_until_videotoolbox_is_seen(self) -> None:
4249
tools = Mock()
4350
with patch("videomerge.gui.platform.system", return_value="Darwin"), patch(

videomerge/gui.py

Lines changed: 47 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,31 +1626,49 @@ def _show_system_notification(title: str, body: str, sound: bool) -> tuple[bool,
16261626

16271627
def _show_macos_notification(title: str, body: str, sound: bool) -> tuple[bool, str]:
16281628
permission_result = _request_macos_notification_permission()
1629-
script = f'display notification {_apple_script_quote(body)} with title {_apple_script_quote(title)}'
1630-
if sound:
1631-
script += ' sound name "Glass"'
1632-
process = subprocess.run(
1633-
["osascript", "-e", script],
1634-
stdout=subprocess.PIPE,
1635-
stderr=subprocess.PIPE,
1636-
text=True,
1637-
encoding="utf-8",
1638-
errors="replace",
1639-
check=False,
1640-
**subprocess_window_kwargs(),
1641-
)
1642-
if process.returncode == 0:
1643-
return True, "macOS notification sent with osascript."
1644-
framework_result = _show_macos_notification_with_framework(title, body, sound)
1645-
if framework_result[0]:
1646-
return framework_result
1647-
message = process.stderr.strip() or framework_result[1] or permission_result[1] or "macOS notification failed."
1629+
_request_macos_dock_attention()
1630+
errors = []
1631+
for notifier in (_show_macos_notification_with_nsusernotification, _show_macos_notification_with_usernotifications):
1632+
result = notifier(title, body, sound)
1633+
if result[0]:
1634+
return result
1635+
errors.append(result[1])
1636+
message = "; ".join(error for error in errors if error) or permission_result[1] or "macOS notification failed."
16481637
return False, message
16491638

16501639

1651-
def _show_macos_notification_with_framework(title: str, body: str, sound: bool) -> tuple[bool, str]:
1640+
def _request_macos_dock_attention() -> tuple[bool, str]:
1641+
try:
1642+
from AppKit import NSApplication, NSCriticalRequest # type: ignore[import-not-found]
1643+
1644+
app = NSApplication.sharedApplication()
1645+
app.requestUserAttention_(NSCriticalRequest)
1646+
return True, "Dock attention requested."
1647+
except Exception as exc:
1648+
return False, str(exc)
1649+
1650+
1651+
def _show_macos_notification_with_nsusernotification(title: str, body: str, sound: bool) -> tuple[bool, str]:
1652+
try:
1653+
from Foundation import ( # type: ignore[import-not-found]
1654+
NSUserNotification,
1655+
NSUserNotificationCenter,
1656+
NSUserNotificationDefaultSoundName,
1657+
)
1658+
except Exception as exc:
1659+
return False, f"NSUserNotification unavailable: {exc}"
1660+
1661+
notification = NSUserNotification.alloc().init()
1662+
notification.setTitle_(title)
1663+
notification.setInformativeText_(body)
1664+
if sound:
1665+
notification.setSoundName_(NSUserNotificationDefaultSoundName)
1666+
NSUserNotificationCenter.defaultUserNotificationCenter().deliverNotification_(notification)
1667+
return True, "macOS notification sent by VideoMergingTool."
1668+
1669+
1670+
def _show_macos_notification_with_usernotifications(title: str, body: str, sound: bool) -> tuple[bool, str]:
16521671
try:
1653-
from Foundation import NSDate # type: ignore[import-not-found]
16541672
from UserNotifications import ( # type: ignore[import-not-found]
16551673
UNMutableNotificationContent,
16561674
UNNotificationRequest,
@@ -1667,8 +1685,6 @@ def _show_macos_notification_with_framework(title: str, body: str, sound: bool)
16671685
content.setBody_(body)
16681686
if sound:
16691687
content.setSound_(UNNotificationSound.defaultSound())
1670-
if NSDate is None:
1671-
return False, "Foundation unavailable."
16721688
trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval_repeats_(0.1, False)
16731689
request = UNNotificationRequest.requestWithIdentifier_content_trigger_(identifier, content, trigger)
16741690
center = UNUserNotificationCenter.currentNotificationCenter()
@@ -1677,7 +1693,7 @@ def _show_macos_notification_with_framework(title: str, body: str, sound: bool)
16771693

16781694

16791695
def _show_windows_notification(title: str, body: str, sound: bool) -> tuple[bool, str]:
1680-
script = _windows_toast_script(title, body, sound)
1696+
script = _windows_notification_script(title, body, sound)
16811697
subprocess.Popen(
16821698
["powershell", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-Command", script],
16831699
stdout=subprocess.DEVNULL,
@@ -1687,67 +1703,35 @@ def _show_windows_notification(title: str, body: str, sound: bool) -> tuple[bool
16871703
return True, "Windows notification requested."
16881704

16891705

1690-
def _windows_toast_script(title: str, body: str, sound: bool) -> str:
1691-
title_xml = _xml_escape(title)
1692-
body_xml = _xml_escape(body)
1693-
audio = '<audio src="ms-winsoundevent:Notification.Default"/>' if sound else '<audio silent="true"/>'
1694-
toast_xml = (
1695-
"<toast>"
1696-
"<visual><binding template=\"ToastGeneric\">"
1697-
f"<text>{title_xml}</text><text>{body_xml}</text>"
1698-
"</binding></visual>"
1699-
f"{audio}"
1700-
"</toast>"
1701-
)
1702-
toast_ps = _powershell_single_quote(toast_xml)
1706+
def _windows_notification_script(title: str, body: str, sound: bool) -> str:
17031707
title_ps = _powershell_single_quote(title)
17041708
body_ps = _powershell_single_quote(body)
1709+
sound_ps = "$true" if sound else "$false"
17051710
return f"""
1706-
$ErrorActionPreference = 'SilentlyContinue'
1711+
$ErrorActionPreference = 'Stop'
17071712
try {{
1708-
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
1709-
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
1710-
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
1711-
$xml.LoadXml({toast_ps})
1712-
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
1713-
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('VideoMergingTool')
1714-
$notifier.Show($toast)
1715-
Start-Sleep -Milliseconds 500
1716-
exit 0
1717-
}} catch {{
17181713
Add-Type -AssemblyName System.Windows.Forms
17191714
Add-Type -AssemblyName System.Drawing
17201715
$notify = New-Object System.Windows.Forms.NotifyIcon
17211716
$notify.Icon = [System.Drawing.SystemIcons]::Information
17221717
$notify.BalloonTipTitle = {title_ps}
17231718
$notify.BalloonTipText = {body_ps}
17241719
$notify.Visible = $true
1720+
if ({sound_ps}) {{ [System.Media.SystemSounds]::Asterisk.Play() }}
17251721
$notify.ShowBalloonTip(5000)
1726-
if ({'$true' if sound else '$false'}) {{ [System.Media.SystemSounds]::Asterisk.Play() }}
1722+
[System.Windows.Forms.Application]::DoEvents()
17271723
Start-Sleep -Seconds 6
17281724
$notify.Dispose()
1725+
}} catch {{
1726+
if ({sound_ps}) {{ [System.Media.SystemSounds]::Asterisk.Play() }}
17291727
}}
17301728
"""
17311729

17321730

1733-
def _apple_script_quote(value: str) -> str:
1734-
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
1735-
1736-
17371731
def _powershell_single_quote(value: str) -> str:
17381732
return "'" + value.replace("'", "''") + "'"
17391733

17401734

1741-
def _xml_escape(value: str) -> str:
1742-
return (
1743-
value.replace("&", "&amp;")
1744-
.replace("<", "&lt;")
1745-
.replace(">", "&gt;")
1746-
.replace('"', "&quot;")
1747-
.replace("'", "&apos;")
1748-
)
1749-
1750-
17511735
def _build_merge_command(payload: dict[str, object]) -> list[str]:
17521736
if getattr(sys, "frozen", False):
17531737
cmd = [sys.executable, "merge", str(payload["input_dir"])]

0 commit comments

Comments
 (0)