feat: allow macOS tray to maintain position (#47838)

* feat: allow macOS tray to maintain position

* refactor: just use guid

* test: fixup tests

* docs: clarify UUID format
This commit is contained in:
Shelley Vohr 2025-08-07 19:25:50 +02:00 committed by GitHub
commit a0d983e4b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 101 additions and 34 deletions

View file

@ -79,7 +79,15 @@ app.whenReady().then(() => {
### `new Tray(image, [guid])` ### `new Tray(image, [guid])`
* `image` ([NativeImage](native-image.md) | string) * `image` ([NativeImage](native-image.md) | string)
* `guid` string (optional) _Windows_ - Assigns a GUID to the tray icon. If the executable is signed and the signature contains an organization in the subject line then the GUID is permanently associated with that signature. OS level settings like the position of the tray icon in the system tray will persist even if the path to the executable changes. If the executable is not code-signed then the GUID is permanently associated with the path to the executable. Changing the path to the executable will break the creation of the tray icon and a new GUID must be used. However, it is highly recommended to use the GUID parameter only in conjunction with code-signed executable. If an App defines multiple tray icons then each icon must use a separate GUID. * `guid` string (optional) _Windows_ _macOS_ - A unique string used to identify the tray icon. Must adhere to [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier) format.
**Windows**
On Windows, if the executable is signed and the signature contains an organization in the subject line then the GUID is permanently associated with that signature. OS level settings like the position of the tray icon in the system tray will persist even if the path to the executable changes. If the executable is not code-signed then the GUID is permanently associated with the path to the executable. Changing the path to the executable will break the creation of the tray icon and a new GUID must be used. However, it is highly recommended to use the GUID parameter only in conjunction with code-signed executable. If an App defines multiple tray icons then each icon must use a separate GUID.
**MacOS**
On macOS, the `guid` is a string used to uniquely identify the tray icon and allow it to retain its position between relaunches. Using the same string for a new tray item will create it in the same position as the previous tray item to use the string.
Creates a new tray icon associated with the `image`. Creates a new tray icon associated with the `image`.
@ -327,6 +335,10 @@ Returns [`Rectangle`](structures/rectangle.md)
The `bounds` of this tray icon as `Object`. The `bounds` of this tray icon as `Object`.
#### `tray.getGUID()` _macOS_ _Windows_
Returns `string | null` - The GUID used to uniquely identify the tray icon and allow it to retain its position between relaunches, or null if none is set.
#### `tray.isDestroyed()` #### `tray.isDestroyed()`
Returns `boolean` - Whether the tray icon is destroyed. Returns `boolean` - Whether the tray icon is destroyed.

View file

@ -52,10 +52,12 @@ gin::DeprecatedWrapperInfo Tray::kWrapperInfo = {gin::kEmbedderNativeGin};
Tray::Tray(v8::Isolate* isolate, Tray::Tray(v8::Isolate* isolate,
v8::Local<v8::Value> image, v8::Local<v8::Value> image,
std::optional<UUID> guid) std::optional<base::Uuid> guid)
: tray_icon_(TrayIcon::Create(guid)) { : guid_(guid), tray_icon_(TrayIcon::Create(guid)) {
SetImage(isolate, image); SetImage(isolate, image);
tray_icon_->AddObserver(this); tray_icon_->AddObserver(this);
if (guid.has_value())
tray_icon_->SetAutoSaveName(guid.value().AsLowercaseString());
} }
Tray::~Tray() = default; Tray::~Tray() = default;
@ -63,19 +65,17 @@ Tray::~Tray() = default;
// static // static
gin_helper::Handle<Tray> Tray::New(gin_helper::ErrorThrower thrower, gin_helper::Handle<Tray> Tray::New(gin_helper::ErrorThrower thrower,
v8::Local<v8::Value> image, v8::Local<v8::Value> image,
std::optional<UUID> guid, std::optional<base::Uuid> guid,
gin::Arguments* args) { gin::Arguments* args) {
if (!Browser::Get()->is_ready()) { if (!Browser::Get()->is_ready()) {
thrower.ThrowError("Cannot create Tray before app is ready"); thrower.ThrowError("Cannot create Tray before app is ready");
return {}; return {};
} }
#if BUILDFLAG(IS_WIN)
if (!guid.has_value() && args->Length() > 1) { if (!guid.has_value() && args->Length() > 1) {
thrower.ThrowError("Invalid GUID format"); thrower.ThrowError("Invalid GUID format - GUID must be a string");
return {}; return {};
} }
#endif
// Error thrown by us will be dropped when entering V8. // Error thrown by us will be dropped when entering V8.
// Make sure to abort early and propagate the error to JS. // Make sure to abort early and propagate the error to JS.
@ -392,6 +392,15 @@ gfx::Rect Tray::GetBounds() {
return tray_icon_->GetBounds(); return tray_icon_->GetBounds();
} }
v8::Local<v8::Value> Tray::GetGUID() {
if (!CheckAlive())
return {};
auto* isolate = JavascriptEnvironment::GetIsolate();
if (!guid_)
return v8::Null(isolate);
return gin::ConvertToV8(isolate, guid_.value());
}
bool Tray::CheckAlive() { bool Tray::CheckAlive() {
if (!tray_icon_) { if (!tray_icon_) {
v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); v8::Isolate* isolate = JavascriptEnvironment::GetIsolate();
@ -424,6 +433,7 @@ void Tray::FillObjectTemplate(v8::Isolate* isolate,
.SetMethod("closeContextMenu", &Tray::CloseContextMenu) .SetMethod("closeContextMenu", &Tray::CloseContextMenu)
.SetMethod("setContextMenu", &Tray::SetContextMenu) .SetMethod("setContextMenu", &Tray::SetContextMenu)
.SetMethod("getBounds", &Tray::GetBounds) .SetMethod("getBounds", &Tray::GetBounds)
.SetMethod("getGUID", &Tray::GetGUID)
.Build(); .Build();
} }

View file

@ -45,7 +45,7 @@ class Tray final : public gin_helper::DeprecatedWrappable<Tray>,
// gin_helper::Constructible // gin_helper::Constructible
static gin_helper::Handle<Tray> New(gin_helper::ErrorThrower thrower, static gin_helper::Handle<Tray> New(gin_helper::ErrorThrower thrower,
v8::Local<v8::Value> image, v8::Local<v8::Value> image,
std::optional<UUID> guid, std::optional<base::Uuid> guid,
gin::Arguments* args); gin::Arguments* args);
static void FillObjectTemplate(v8::Isolate*, v8::Local<v8::ObjectTemplate>); static void FillObjectTemplate(v8::Isolate*, v8::Local<v8::ObjectTemplate>);
@ -65,7 +65,7 @@ class Tray final : public gin_helper::DeprecatedWrappable<Tray>,
private: private:
Tray(v8::Isolate* isolate, Tray(v8::Isolate* isolate,
v8::Local<v8::Value> image, v8::Local<v8::Value> image,
std::optional<UUID> guid); std::optional<base::Uuid> guid);
~Tray() override; ~Tray() override;
// TrayIconObserver: // TrayIconObserver:
@ -111,10 +111,12 @@ class Tray final : public gin_helper::DeprecatedWrappable<Tray>,
void SetContextMenu(gin_helper::ErrorThrower thrower, void SetContextMenu(gin_helper::ErrorThrower thrower,
v8::Local<v8::Value> arg); v8::Local<v8::Value> arg);
gfx::Rect GetBounds(); gfx::Rect GetBounds();
v8::Local<v8::Value> GetGUID();
bool CheckAlive(); bool CheckAlive();
v8::Global<v8::Value> menu_; v8::Global<v8::Value> menu_;
std::optional<base::Uuid> guid_;
std::unique_ptr<TrayIcon> tray_icon_; std::unique_ptr<TrayIcon> tray_icon_;
}; };

View file

@ -16,6 +16,8 @@ gfx::Rect TrayIcon::GetBounds() {
return {}; return {};
} }
void TrayIcon::SetAutoSaveName(const std::string& name) {}
void TrayIcon::NotifyClicked(const gfx::Rect& bounds, void TrayIcon::NotifyClicked(const gfx::Rect& bounds,
const gfx::Point& location, const gfx::Point& location,
int modifiers) { int modifiers) {

View file

@ -18,7 +18,7 @@ namespace electron {
class TrayIcon { class TrayIcon {
public: public:
static TrayIcon* Create(std::optional<UUID> guid); static TrayIcon* Create(std::optional<base::Uuid> guid);
#if BUILDFLAG(IS_WIN) #if BUILDFLAG(IS_WIN)
using ImageType = HICON; using ImageType = HICON;
@ -99,6 +99,8 @@ class TrayIcon {
// Returns the bounds of tray icon. // Returns the bounds of tray icon.
virtual gfx::Rect GetBounds(); virtual gfx::Rect GetBounds();
virtual void SetAutoSaveName(const std::string& name);
void AddObserver(TrayIconObserver* obs) { observers_.AddObserver(obs); } void AddObserver(TrayIconObserver* obs) { observers_.AddObserver(obs); }
void RemoveObserver(TrayIconObserver* obs) { observers_.RemoveObserver(obs); } void RemoveObserver(TrayIconObserver* obs) { observers_.RemoveObserver(obs); }

View file

@ -35,6 +35,7 @@ class TrayIconCocoa : public TrayIcon {
void CloseContextMenu() override; void CloseContextMenu() override;
void SetContextMenu(raw_ptr<ElectronMenuModel> menu_model) override; void SetContextMenu(raw_ptr<ElectronMenuModel> menu_model) override;
gfx::Rect GetBounds() override; gfx::Rect GetBounds() override;
void SetAutoSaveName(const std::string& name) override;
base::WeakPtr<TrayIconCocoa> GetWeakPtr() { base::WeakPtr<TrayIconCocoa> GetWeakPtr() {
return weak_factory_.GetWeakPtr(); return weak_factory_.GetWeakPtr();

View file

@ -11,6 +11,7 @@
#include "base/message_loop/message_pump_apple.h" #include "base/message_loop/message_pump_apple.h"
#include "base/strings/sys_string_conversions.h" #include "base/strings/sys_string_conversions.h"
#include "base/task/current_thread.h" #include "base/task/current_thread.h"
#include "base/uuid.h"
#include "content/public/browser/browser_task_traits.h" #include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h" #include "content/public/browser/browser_thread.h"
#include "shell/browser/ui/cocoa/NSString+ANSI.h" #include "shell/browser/ui/cocoa/NSString+ANSI.h"
@ -68,6 +69,10 @@
[self setFrame:[statusItem_ button].frame]; [self setFrame:[statusItem_ button].frame];
} }
- (void)setAutosaveName:(NSString*)name {
statusItem_.autosaveName = name;
}
- (void)updateTrackingAreas { - (void)updateTrackingAreas {
// Use NSTrackingArea for listening to mouseEnter, mouseExit, and mouseMove // Use NSTrackingArea for listening to mouseEnter, mouseExit, and mouseMove
// events. // events.
@ -420,8 +425,12 @@ gfx::Rect TrayIconCocoa::GetBounds() {
return gfx::ScreenRectFromNSRect([status_item_view_ window].frame); return gfx::ScreenRectFromNSRect([status_item_view_ window].frame);
} }
void TrayIconCocoa::SetAutoSaveName(const std::string& name) {
[status_item_view_ setAutosaveName:base::SysUTF8ToNSString(name)];
}
// static // static
TrayIcon* TrayIcon::Create(std::optional<UUID> guid) { TrayIcon* TrayIcon::Create(std::optional<base::Uuid> guid) {
return new TrayIconCocoa; return new TrayIconCocoa;
} }

View file

@ -112,7 +112,7 @@ ui::StatusIconLinux* TrayIconLinux::GetStatusIcon() {
} }
// static // static
TrayIcon* TrayIcon::Create(std::optional<UUID> guid) { TrayIcon* TrayIcon::Create(std::optional<base::Uuid> guid) {
return new TrayIconLinux; return new TrayIconLinux;
} }

View file

@ -8,7 +8,7 @@
namespace electron { namespace electron {
// static // static
TrayIcon* TrayIcon::Create(std::optional<UUID> guid) { TrayIcon* TrayIcon::Create(std::optional<base::Uuid> guid) {
static NotifyIconHost host; static NotifyIconHost host;
return host.CreateNotifyIcon(guid); return host.CreateNotifyIcon(guid);
} }

View file

@ -190,21 +190,32 @@ NotifyIconHost::~NotifyIconHost() {
delete ptr; delete ptr;
} }
NotifyIcon* NotifyIconHost::CreateNotifyIcon(std::optional<UUID> guid) { NotifyIcon* NotifyIconHost::CreateNotifyIcon(std::optional<base::Uuid> guid) {
if (guid.has_value()) { std::string guid_str =
for (NotifyIcons::const_iterator i(notify_icons_.begin()); guid.has_value() ? guid.value().AsLowercaseString() : "";
i != notify_icons_.end(); ++i) { UUID uid = GUID_NULL;
auto* current_win_icon = static_cast<NotifyIcon*>(*i); if (!guid_str.empty()) {
if (current_win_icon->guid() == guid.value()) { if (guid_str[0] == '{' && guid_str[guid_str.length() - 1] == '}') {
LOG(WARNING) guid_str = guid_str.substr(1, guid_str.length() - 2);
<< "Guid already in use. Existing tray entry will be replaced."; }
unsigned char* uid_cstr = (unsigned char*)guid_str.c_str();
RPC_STATUS result = UuidFromStringA(uid_cstr, &uid);
if (result != RPC_S_INVALID_STRING_UUID) {
for (NotifyIcons::const_iterator i(notify_icons_.begin());
i != notify_icons_.end(); ++i) {
auto* current_win_icon = static_cast<NotifyIcon*>(*i);
if (current_win_icon->guid() == uid) {
LOG(WARNING)
<< "Guid already in use. Existing tray entry will be replaced.";
}
} }
} }
} }
auto* notify_icon = auto* notify_icon =
new NotifyIcon(this, NextIconId(), window_, kNotifyIconMessage, new NotifyIcon(this, NextIconId(), window_, kNotifyIconMessage,
guid.has_value() ? guid.value() : GUID_DEFAULT); uid == GUID_NULL ? GUID_DEFAULT : uid);
notify_icons_.push_back(notify_icon); notify_icons_.push_back(notify_icon);
return notify_icon; return notify_icon;

View file

@ -27,7 +27,7 @@ class NotifyIconHost {
NotifyIconHost(const NotifyIconHost&) = delete; NotifyIconHost(const NotifyIconHost&) = delete;
NotifyIconHost& operator=(const NotifyIconHost&) = delete; NotifyIconHost& operator=(const NotifyIconHost&) = delete;
NotifyIcon* CreateNotifyIcon(std::optional<UUID> guid); NotifyIcon* CreateNotifyIcon(std::optional<base::Uuid> guid);
void Remove(NotifyIcon* notify_icon); void Remove(NotifyIcon* notify_icon);
private: private:

View file

@ -7,6 +7,8 @@
#include <string> #include <string>
#include "base/strings/string_util.h"
#include "base/uuid.h"
#include "gin/converter.h" #include "gin/converter.h"
#if BUILDFLAG(IS_WIN) #if BUILDFLAG(IS_WIN)
@ -36,18 +38,40 @@ typedef struct {
namespace gin { namespace gin {
template <>
struct Converter<base::Uuid> {
static bool FromV8(v8::Isolate* isolate,
v8::Local<v8::Value> val,
base::Uuid* out) {
std::string guid;
if (!gin::ConvertFromV8(isolate, val, &guid))
return false;
base::Uuid parsed = base::Uuid::ParseLowercase(base::ToLowerASCII(guid));
if (!parsed.is_valid())
return false;
*out = parsed;
return true;
}
static v8::Local<v8::Value> ToV8(v8::Isolate* isolate, base::Uuid val) {
const std::string guid = val.AsLowercaseString();
return gin::ConvertToV8(isolate, guid);
}
};
#if BUILDFLAG(IS_WIN)
template <> template <>
struct Converter<UUID> { struct Converter<UUID> {
static bool FromV8(v8::Isolate* isolate, static bool FromV8(v8::Isolate* isolate,
v8::Local<v8::Value> val, v8::Local<v8::Value> val,
UUID* out) { UUID* out) {
#if BUILDFLAG(IS_WIN)
std::string guid; std::string guid;
if (!gin::ConvertFromV8(isolate, val, &guid)) if (!gin::ConvertFromV8(isolate, val, &guid))
return false; return false;
UUID uid; UUID uid;
if (!guid.empty()) { if (!guid.empty()) {
if (guid[0] == '{' && guid[guid.length() - 1] == '}') { if (guid[0] == '{' && guid[guid.length() - 1] == '}') {
guid = guid.substr(1, guid.length() - 2); guid = guid.substr(1, guid.length() - 2);
@ -62,12 +86,8 @@ struct Converter<UUID> {
} }
} }
return false; return false;
#else
return false;
#endif
} }
static v8::Local<v8::Value> ToV8(v8::Isolate* isolate, UUID val) { static v8::Local<v8::Value> ToV8(v8::Isolate* isolate, UUID val) {
#if BUILDFLAG(IS_WIN)
const GUID GUID_NULL = {}; const GUID GUID_NULL = {};
if (val == GUID_NULL) { if (val == GUID_NULL) {
return v8::String::Empty(isolate); return v8::String::Empty(isolate);
@ -75,11 +95,9 @@ struct Converter<UUID> {
std::wstring uid = base::win::WStringFromGUID(val); std::wstring uid = base::win::WStringFromGUID(val);
return StringToV8(isolate, base::SysWideToUTF8(uid)); return StringToV8(isolate, base::SysWideToUTF8(uid));
} }
#else
return v8::Undefined(isolate);
#endif
} }
}; };
#endif
} // namespace gin } // namespace gin

View file

@ -30,13 +30,13 @@ describe('tray module', () => {
}).to.throw(/Failed to load image from path (.+)/); }).to.throw(/Failed to load image from path (.+)/);
}); });
ifit(process.platform === 'win32')('throws a descriptive error if an invalid guid is given', () => { ifit(process.platform !== 'linux')('throws a descriptive error if an invalid guid is given', () => {
expect(() => { expect(() => {
tray = new Tray(nativeImage.createEmpty(), 'I am not a guid'); tray = new Tray(nativeImage.createEmpty(), 'I am not a guid');
}).to.throw('Invalid GUID format'); }).to.throw('Invalid GUID format');
}); });
ifit(process.platform === 'win32')('accepts a valid guid', () => { ifit(process.platform !== 'linux')('accepts a valid guid', () => {
expect(() => { expect(() => {
tray = new Tray(nativeImage.createEmpty(), '0019A433-3526-48BA-A66C-676742C0FEFB'); tray = new Tray(nativeImage.createEmpty(), '0019A433-3526-48BA-A66C-676742C0FEFB');
}).to.not.throw(); }).to.not.throw();