Localized application names on macOS (#50)

Co-authored-by: Exidex <16986685+exidex@users.noreply.github.com>
This commit is contained in:
Benno 2025-02-15 20:09:05 +01:00 committed by GitHub
parent 0c6b0282c2
commit 652af98544
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 159 additions and 45 deletions

View file

@ -9,6 +9,12 @@ For changes in `@project-gauntlet/tools` see [separate CHANGELOG.md](https://git
## [Unreleased]
- Added localization support for macOS application names
- Added plugin preference `Bundle Name Lang` of enum type
- `localized` option - use localized name of bundle if available - this is the default
- `default` option - use default name of bundle (usually english)
- On macOS use app stem name as a fallback if the bundle name is empty
- Fixes empty names of some apps, like "Creality Print" which have an empty bundle name
- Added --version flag to CLI to display Gauntlet version
- Slightly improved close-on-unfocus behaviour of main window on X11
- Global shortcut is now executed on key press, instead of key release

1
Cargo.lock generated
View file

@ -4109,6 +4109,7 @@ dependencies = [
"resvg",
"serde",
"smithay-client-toolkit",
"sys-locale",
"tokio",
"tokio-util",
"tracing",

View file

@ -16,6 +16,17 @@ type = 'bool'
default = true
description = "Enables experimental window tracking"
[[entrypoint.preferences]]
id = "bundleNameLang"
name = "Bundle Name Lang"
type = "enum"
default = "localized"
description = "Language of the bundle name"
enum_values = [
{ label = 'Default', value = 'default' },
{ label = 'Localized', value = 'localized' },
]
[[entrypoint]]
id = 'windows'
name = 'All Open Windows'
@ -46,7 +57,7 @@ read = [
# technically only uses locations defined by XDG Desktop Entry Specification, but
# the spec allows for customization via XDG_DATA_DIRS and XDG_DATA_HOME env vars so it can be any path
"/",
"C:\\"
"C:\\",
]
[[supported_system]]

View file

@ -7,6 +7,7 @@ import {
macos_app_from_arbitrary_path,
macos_app_from_path,
macos_application_dirs,
macos_get_localized_language,
macos_major_version,
macos_open_application,
macos_open_setting_13_and_post,
@ -20,10 +21,25 @@ import { applicationEventLoopX11, focusX11Window } from "./window/x11";
import { applicationEventLoopWayland, focusWaylandWindow } from "./window/wayland";
import { windows_app_from_path, windows_application_dirs, windows_open_application } from "gauntlet:bridge/internal-windows";
type EntrypointPreferences = { experimentalWindowTracking: boolean };
type EntrypointPreferences = { experimentalWindowTracking: boolean, bundleNameLang: "default" | "localized" };
export default async function Applications(context: GeneratorContext<object, EntrypointPreferences>): Promise<void | (() => void)> {
const { add, remove, get, getAll, entrypointPreferences: { experimentalWindowTracking } } = context;
const { add, remove, get, getAll, entrypointPreferences: { experimentalWindowTracking, bundleNameLang } } = context;
let lang: string | undefined;
switch (bundleNameLang) {
case "default": {
lang = undefined;
break;
}
case "localized": {
lang = macos_get_localized_language();
break;
}
default: {
throw new Error("Unknown bundle name type")
}
}
switch (current_os()) {
case "linux": {
@ -105,7 +121,7 @@ export default async function Applications(context: GeneratorContext<object, Ent
const majorVersion = macos_major_version();
if (majorVersion >= 13) {
for (const setting of macos_settings_13_and_post()) {
for (const setting of macos_settings_13_and_post(lang)) {
add(`settings:${setting.preferences_id}`, {
name: setting.name,
actions: [
@ -137,7 +153,7 @@ export default async function Applications(context: GeneratorContext<object, Ent
}
for (const path of macos_system_applications()) {
const app = await macos_app_from_path(path)
const app = await macos_app_from_path(path, lang)
if (app) {
switch (app.type) {
case "add": {
@ -164,7 +180,7 @@ export default async function Applications(context: GeneratorContext<object, Ent
return await genericGenerator<MacOSDesktopApplicationData>(
macos_application_dirs(),
path => macos_app_from_arbitrary_path(path),
path => macos_app_from_arbitrary_path(path, lang),
(_id, data) => ({
name: data.name,
actions: [
@ -279,5 +295,3 @@ async function genericGenerator<DATA>(
watcher.close()
}
}

View file

@ -4,6 +4,7 @@ export {
macos_application_dirs,
macos_major_version,
macos_open_application,
macos_get_localized_language,
macos_open_setting_13_and_post,
macos_open_setting_pre_13,
macos_settings_13_and_post,

16
js/typings/index.d.ts vendored
View file

@ -162,15 +162,16 @@ declare module "gauntlet:bridge/internal-linux" {
declare module "gauntlet:bridge/internal-macos" {
function macos_major_version(): number
function macos_settings_pre_13(): MacOSDesktopSettingsPre13Data[]
function macos_settings_13_and_post(): MacOSDesktopSettings13AndPostData[]
function macos_settings_13_and_post(lang: string | undefined): MacOSDesktopSettings13AndPostData[]
function macos_open_setting_13_and_post(preferences_id: String): void
function macos_open_setting_pre_13(setting_path: String): void
function macos_system_applications(): string[]
function macos_application_dirs(): string[]
function macos_app_from_path(path: string): Promise<undefined | DesktopPathAction<MacOSDesktopApplicationData>>
function macos_app_from_arbitrary_path(path: string): Promise<undefined | DesktopPathAction<MacOSDesktopApplicationData>>
function macos_app_from_path(path: string, lang: string | undefined): Promise<undefined | DesktopPathAction<MacOSDesktopApplicationData>>
function macos_app_from_arbitrary_path(path: string, lang: string | undefined): Promise<undefined | DesktopPathAction<MacOSDesktopApplicationData>>
function macos_open_application(app_path: String): void
function macos_get_localized_language(): string | undefined
}
declare module "gauntlet:bridge/internal-windows" {
@ -196,15 +197,16 @@ declare module "ext:core/ops" {
function macos_major_version(): number
function macos_settings_pre_13(): MacOSDesktopSettingsPre13Data[]
function macos_settings_13_and_post(): MacOSDesktopSettings13AndPostData[]
function macos_settings_13_and_post(lang: string | undefined): MacOSDesktopSettings13AndPostData[]
function macos_open_setting_13_and_post(preferences_id: String): void
function macos_open_setting_pre_13(setting_path: String): void
function macos_system_applications(): string[]
function macos_application_dirs(): string[]
function macos_app_from_path(path: string): Promise<undefined | DesktopPathAction<MacOSDesktopApplicationData>>
function macos_app_from_arbitrary_path(path: string): Promise<undefined | DesktopPathAction<MacOSDesktopApplicationData>>
function macos_app_from_path(path: string, lang: string | undefined): Promise<undefined | DesktopPathAction<MacOSDesktopApplicationData>>
function macos_app_from_arbitrary_path(path: string, lang: string | undefined): Promise<undefined | DesktopPathAction<MacOSDesktopApplicationData>>
function macos_open_application(app_path: String): void
function macos_get_localized_language(): string | undefined
function windows_application_dirs(): string[]
function windows_open_application(path: string): void
@ -479,4 +481,4 @@ type X11ApplicationEventDesktopFileNamePropertyNotify = {
type: "DesktopFileNamePropertyNotify",
id: X11WindowId,
desktop_file_name: string
};
};

View file

@ -34,6 +34,7 @@ numbat = "1.14.0"
which = "7.0.1"
uuid = "1.11.0"
open = "5"
sys-locale = "0.3.2"
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
libc = "0.2"

View file

@ -430,6 +430,7 @@ deno_core::extension!(
crate::plugins::applications::macos_app_from_arbitrary_path,
crate::plugins::applications::macos_app_from_path,
crate::plugins::applications::macos_open_application,
crate::plugins::applications::macos_get_localized_language,
],
esm_entry_point = "ext:gauntlet/internal-macos/bootstrap.js",
esm = [

View file

@ -9,6 +9,7 @@ use image::imageops::FilterType;
use image::ImageFormat;
use serde::Deserialize;
use serde::Serialize;
use sys_locale::get_locale;
use tokio::runtime::Handle;
use tokio::sync::mpsc::Receiver;
use tokio::task::spawn_blocking;
@ -140,15 +141,21 @@ pub fn macos_major_version() -> u8 {
#[cfg(target_os = "macos")]
#[op2(async)]
#[serde]
pub async fn macos_app_from_path(#[string] path: String) -> anyhow::Result<Option<DesktopPathAction>> {
Ok(spawn_blocking(|| macos::macos_app_from_path(&PathBuf::from(path))).await?)
pub async fn macos_app_from_path(
#[string] path: String,
#[string] lang: Option<String>,
) -> anyhow::Result<Option<DesktopPathAction>> {
Ok(spawn_blocking(|| macos::macos_app_from_path(&PathBuf::from(path), lang)).await?)
}
#[cfg(target_os = "macos")]
#[op2(async)]
#[serde]
pub async fn macos_app_from_arbitrary_path(#[string] path: String) -> anyhow::Result<Option<DesktopPathAction>> {
Ok(spawn_blocking(|| macos::macos_app_from_arbitrary_path(PathBuf::from(path))).await?)
pub async fn macos_app_from_arbitrary_path(
#[string] path: String,
#[string] lang: Option<String>,
) -> anyhow::Result<Option<DesktopPathAction>> {
Ok(spawn_blocking(|| macos::macos_app_from_arbitrary_path(PathBuf::from(path), lang)).await?)
}
#[cfg(target_os = "macos")]
@ -189,8 +196,8 @@ pub fn macos_settings_pre_13() -> Vec<DesktopSettingsPre13Data> {
#[cfg(target_os = "macos")]
#[op2]
#[serde]
pub fn macos_settings_13_and_post() -> Vec<DesktopSettings13AndPostData> {
macos::macos_settings_13_and_post()
pub fn macos_settings_13_and_post(#[string] lang: Option<String>) -> Vec<DesktopSettings13AndPostData> {
macos::macos_settings_13_and_post(lang)
}
#[cfg(target_os = "macos")]
@ -209,6 +216,17 @@ pub fn macos_open_setting_pre_13(#[string] setting_path: String) -> anyhow::Resu
Ok(())
}
#[cfg(target_os = "macos")]
#[op2]
#[string]
pub fn macos_get_localized_language() -> Option<String> {
get_locale()?
.split("-")
.collect::<Vec<&str>>()
.get(0)
.map(|s| s.to_string())
}
#[cfg(unix)]
pub fn spawn_detached<I, S>(path: &str, args: I) -> std::io::Result<()>
where

View file

@ -31,6 +31,7 @@ use objc2_foundation::NSSize;
use objc2_foundation::NSString;
use objc2_foundation::NSZeroRect;
use plist::Dictionary;
use plist::Value;
use regex::Regex;
use serde::Deserialize;
@ -100,7 +101,7 @@ pub fn macos_application_dirs() -> Vec<PathBuf> {
all_applications
}
pub fn macos_app_from_arbitrary_path(path: PathBuf) -> Option<DesktopPathAction> {
pub fn macos_app_from_arbitrary_path(path: PathBuf, lang: Option<String>) -> Option<DesktopPathAction> {
let path = path
.ancestors()
.into_iter()
@ -123,29 +124,68 @@ pub fn macos_app_from_arbitrary_path(path: PathBuf) -> Option<DesktopPathAction>
return None;
};
macos_app_from_path(path)
macos_app_from_path(path, lang)
}
pub fn macos_app_from_path(path: &Path) -> Option<DesktopPathAction> {
if !path.is_dir() {
return None;
}
fn get_bundle_name(app_path: &Path) -> String {
let info_path = app_path.join("Contents").join("Info.plist");
let name = path
let info: Option<Info> = plist::from_file(info_path).ok();
let fallback_name = app_path
.file_stem()
.expect(&format!("invalid path: {:?}", path))
.expect(&format!("invalid path: {:?}", app_path))
.to_str()
.expect("non-uft8 paths are not supported")
.to_string();
let info_path = path.join("Contents").join("Info.plist");
let info: Option<Info> = plist::from_file(info_path).ok();
let name = info
let mut bundle_name = info
.as_ref()
.and_then(|info| info.bundle_display_name.clone().or_else(|| info.bundle_name.clone()))
.unwrap_or(name);
.unwrap_or(fallback_name.clone());
if bundle_name.is_empty() {
bundle_name = fallback_name;
}
bundle_name
}
fn get_localized_name(path: &Path, preferred_language: &str) -> Option<String> {
let localized_info: Option<InfoPlist> = plist::from_file(path).ok();
if let Some(localized_info) = localized_info {
// get language info. first try to use display name, if not available use name
localized_info
.languages
.get(preferred_language)
.and_then(|localized_info| {
localized_info
.bundle_display_name
.clone()
.or_else(|| localized_info.bundle_name.clone())
})
} else {
eprintln!("Error: Could not load plist from path '{}'.", path.display());
None
}
}
pub fn macos_app_from_path(path: &Path, lang: Option<String>) -> Option<DesktopPathAction> {
if !path.is_dir() {
return None;
}
let name = lang
.and_then(|l| {
let info_plist_path = path.join("Contents/Resources/InfoPlist.loctable");
if info_plist_path.is_file() {
get_localized_name(info_plist_path.as_path(), &l)
} else {
None
}
})
.unwrap_or(get_bundle_name(path));
let icon = get_application_icon(&path)
.inspect_err(|err| tracing::error!("error while reading application icon for {:?}: {:?}", path, err))
@ -198,7 +238,7 @@ pub fn macos_settings_pre_13() -> Vec<DesktopSettingsPre13Data> {
all_settings
}
pub fn macos_settings_13_and_post() -> Vec<DesktopSettings13AndPostData> {
pub fn macos_settings_13_and_post(lang: Option<String>) -> Vec<DesktopSettings13AndPostData> {
let sidebar: Vec<SidebarSection> =
plist::from_file("/System/Applications/System Settings.app/Contents/Resources/Sidebar.plist")
.expect("Sidebar.plist doesn't follow expected format");
@ -218,13 +258,24 @@ pub fn macos_settings_13_and_post() -> Vec<DesktopSettings13AndPostData> {
let extensions: HashMap<_, _> = get_extensions_in_dir(PathBuf::from("/System/Library/ExtensionKit/Extensions"))
.into_iter()
.filter_map(|path| {
fn read_plist(path: &Path) -> anyhow::Result<(String, (String, PathBuf))> {
let name = path
fn read_plist(path: &Path, lang: &Option<String>) -> anyhow::Result<(String, (String, PathBuf))> {
let mut name = path
.file_stem()
.expect(&format!("invalid path: {:?}", path))
.to_string_lossy()
.to_string();
let localized_info_path = path.join("Contents/Resources/InfoPlist.loctable");
if !localized_info_path.is_file() {
return Ok((name.clone(), (name, path.to_path_buf())));
}
if let Some(lang) = lang {
name = get_localized_name(localized_info_path.as_path(), lang).unwrap_or(name);
} else {
name = get_localized_name(localized_info_path.as_path(), "en").unwrap_or(name);
}
let info_path = path.join("Contents").join("Info.plist");
let info = plist::from_file::<_, Info>(info_path.as_path()).context(format!(
@ -232,16 +283,10 @@ pub fn macos_settings_13_and_post() -> Vec<DesktopSettings13AndPostData> {
&info_path.display()
))?;
let name = info
.bundle_display_name
.clone()
.or_else(|| info.bundle_name.clone())
.unwrap_or(name);
Ok((info.bundle_id, (name, path.to_path_buf())))
}
read_plist(&path)
read_plist(&path, &lang)
.inspect_err(|err| {
tracing::error!("error while reading system extension Info.plist {:?}: {:?}", path, err)
})
@ -454,6 +499,20 @@ struct Info {
bundle_icon_name: Option<String>,
}
#[derive(Deserialize)]
struct LocalizedInfo {
#[serde(rename = "CFBundleDisplayName")]
bundle_display_name: Option<String>,
#[serde(rename = "CFBundleName")]
bundle_name: Option<String>,
}
#[derive(Deserialize)]
struct InfoPlist {
#[serde(flatten)]
languages: HashMap<String, LocalizedInfo>,
}
#[derive(Deserialize)]
struct SystemVersion {
#[serde(rename = "ProductVersion")]