diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac60c61..033a388 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,8 @@ swift build && REMOVEMACAI_SNAPSHOTS=/tmp/shots .build/debug/removemacai app A new tweak goes in `Sources/removemacai/Tweaks.swift`. Check that macOS still reads its key before adding it, and say how you checked. +Translations live in `Localization/.lproj`: `Localizable.strings` keyed by the English text in the code, and `Localizable.stringsdict` for counts. `tools/build-app.sh` copies them into the app. `tools/check-localization.py ` lists window text that has no translation yet; run it after changing any text the app shows. + Every change RemoveMacAI makes is shown before it happens and can be undone, and a new tweak has to keep that. A tweak that can break something people rely on stays out of the presets. Keep one change per pull request, and say which macOS version and build you tested on. For a bug, the output of `removemacai status` and `sw_vers` helps most. diff --git a/Localization/ru.lproj/Localizable.strings b/Localization/ru.lproj/Localizable.strings new file mode 100644 index 0000000..3af6b63 --- /dev/null +++ b/Localization/ru.lproj/Localizable.strings @@ -0,0 +1,413 @@ +/* Russian translation of RemoveMacAI. The keys are the English strings in the code; + tools/check-localization.py ru lists any that are missing. Plurals are in Localizable.stringsdict. */ + +"RemoveMacAI" = "RemoveMacAI"; +"About RemoveMacAI" = "О программе RemoveMacAI"; +"Reload" = "Обновить"; +"How RemoveMacAI Works" = "Как работает RemoveMacAI"; +"RemoveMacAI on GitHub" = "RemoveMacAI на GitHub"; +"Report a Problem" = "Сообщить о проблеме"; +"Turn Off" = "Выключить"; +"Customize" = "Настроить"; +"Clean Up" = "Очистка"; +"%lld of %lld" = "%1$lld из %2$lld"; +"1 change ready" = "Готово 1 изменение"; +"Nothing has changed yet. You'll see the full list first." = "Пока ничего не изменено. Сначала вы увидите полный список."; +"Discard" = "Отменить выбор"; +"Review and Apply…" = "Просмотреть и применить…"; +"Review 1 change" = "Просмотр 1 изменения"; +"Review %lld changes" = "Просмотр изменений: %lld"; +"Apple Intelligence" = "Apple Intelligence"; +"Turn off every feature" = "Выключить все функции"; +"Turn Apple Intelligence back on. macOS downloads its models again when a feature needs them." = "Снова включить Apple Intelligence. macOS загрузит модели повторно, когда они понадобятся какой-либо функции."; +"Delete the models, about %@" = "Удалить модели, около %@"; +"Ask macOS to finish removing leftover model files" = "Попросить macOS завершить удаление оставшихся файлов моделей"; +"Delete the models" = "Удалить модели"; +"macOS can download them again" = "macOS может загрузить их снова"; +"Turn on or off" = "Включить или выключить"; +"Put back" = "Вернуть"; +"What happens when you click Apply" = "Что произойдёт после нажатия «Применить»"; +"Show exact settings" = "Показать точные настройки"; +"Shows the command or profile key behind each change" = "Показывает команду или ключ профиля для каждого изменения"; +"Cancel" = "Отменить"; +"Apply Change" = "Применить изменение"; +"Apply %lld Changes" = "Применить изменения (%lld)"; +"Kept in place by the RemoveMacAI profile" = "Закреплено профилем RemoveMacAI"; +"This takes a few seconds." = "Это займёт несколько секунд."; +"Remove the profile in System Settings" = "Удалите профиль в Системных настройках"; +"One last step in System Settings" = "Последний шаг в Системных настройках"; +"macOS needs you to remove the RemoveMacAI profile yourself. System Settings is open." = "macOS требует, чтобы вы сами удалили профиль RemoveMacAI. Системные настройки уже открыты."; +"macOS needs you to install the RemoveMacAI profile yourself. System Settings is open." = "macOS требует, чтобы вы сами установили профиль RemoveMacAI. Системные настройки уже открыты."; +"Waiting for you to remove it…" = "Ожидание удаления профиля…"; +"Waiting for you to install it…" = "Ожидание установки профиля…"; +"What's in this profile?" = "Что содержит этот профиль?"; +"It turns off the Apple Intelligence features you chose, points the model downloads at a closed address on your own Mac so macOS can't fetch them again, and holds the settings marked with a lock. Nothing else." = "Он выключает выбранные вами функции Apple Intelligence, перенаправляет загрузку моделей на закрытый адрес на этом же Mac, чтобы macOS не могла скачать их снова, и закрепляет настройки, отмеченные замком. Больше ничего."; +"Show the Profile File" = "Показать файл профиля"; +"Something went wrong" = "Что-то пошло не так"; +"Show System Settings" = "Открыть Системные настройки"; +"All done" = "Готово"; +"Finished, with a few problems" = "Завершено с некоторыми проблемами"; +"Deleted %@ of Apple Intelligence models. macOS clears the files on its own schedule, so Storage settings may show the old size for a while." = "Удалено моделей Apple Intelligence: %@. macOS очищает файлы по своему расписанию, поэтому в настройках хранилища ещё некоторое время может отображаться прежний объём."; +"I make RemoveMacAI by myself in my spare time. If it was useful to you, you can sponsor it on GitHub." = "Я разрабатываю RemoveMacAI один в свободное время. Если программа оказалась полезной, вы можете поддержать её на GitHub."; +"Sponsor on GitHub" = "Поддержать на GitHub"; +"Changed your mind? Undo in the sidebar puts everything back." = "Передумали? Раздел «Отменить» в боковом меню вернёт всё как было."; +"See What Changed" = "Посмотреть изменения"; +"Done" = "Готово"; +"Or start with a preset" = "Или начните с готового набора"; +"Review…" = "Просмотреть…"; +"Apple Intelligence removal builds on [pared](https://github.com/4evy/pared) by 4evy." = "Удаление Apple Intelligence основано на [pared](https://github.com/4evy/pared) от 4evy."; +"Overview" = "Обзор"; +"Apple Intelligence is off" = "Apple Intelligence выключен"; +"Apple Intelligence is on" = "Apple Intelligence включён"; +"%@ of models out of %@ of storage" = "Модели: %1$@ из %2$@ хранилища"; +"Delete the Leftover Models…" = "Удалить оставшиеся модели…"; +"Change What's Kept On" = "Изменить, что оставить включённым"; +"Turn Off with Recommended Settings…" = "Выключить с рекомендуемыми настройками…"; +"Choose What to Turn Off" = "Выбрать, что выключить"; +"You'll see every change first." = "Сначала вы увидите каждое изменение."; +"Turn off Apple Intelligence" = "Выключить Apple Intelligence"; +"Switches off the features below, deletes their models and stops macOS downloading them again. Turn it back on any time and macOS re-downloads what it needs." = "Выключает перечисленные ниже функции, удаляет их модели и не даёт macOS загрузить их снова. Включите в любой момент, и macOS заново загрузит всё необходимое."; +"Keep anything you use" = "Оставьте то, чем пользуетесь"; +"Switch on to keep" = "Включите, чтобы оставить"; +"Asking Apple's asset service…" = "Запрос к сервису ресурсов Apple…"; +"Apple's asset service didn't answer, so the sizes are unknown." = "Сервис ресурсов Apple не ответил, поэтому размеры неизвестны."; +"Total" = "Всего"; +"Apple Intelligence is off, but these models are still on disk." = "Apple Intelligence выключен, но эти модели всё ещё на диске."; +"Delete Models" = "Удалить модели"; +"Models on disk" = "Модели на диске"; +"Managed by another profile on this Mac." = "Управляется другим профилем на этом Mac."; +"Needs macOS %lld.%lld or newer." = "Требуется macOS %1$lld.%2$lld или новее."; +"Partly applied. It stays as it is unless you switch it." = "Применено частично. Останется как есть, пока вы не переключите."; +"Select All" = "Выбрать все"; +"Select Recommended" = "Выбрать рекомендуемые"; +"Deselect All" = "Снять выбор со всех"; +"Select" = "Выбрать"; +"No background items from other apps." = "Нет фоновых объектов других приложений."; +"All users" = "Все пользователи"; +"Updaters, helpers and agents other apps installed. They run whether or not their app is open. Switching one off takes effect at once. Updaters are safe to switch off; a helper can be what makes its app work, such as a VPN or Docker." = "Программы обновления, помощники и агенты, установленные другими приложениями. Они работают независимо от того, открыто ли их приложение. Выключение срабатывает сразу. Программы обновления можно выключать без опаски, а помощник может быть необходим для работы своего приложения, например VPN или Docker."; +"Background Items" = "Фоновые объекты"; +"Find space you can get back" = "Найти место, которое можно освободить"; +"Looks for old installers, aerial videos, Xcode leftovers, app caches and Time Machine snapshots. Nothing moves until you choose." = "Ищет старые установщики, видео заставок Aerial, остатки Xcode, кэши приложений и снимки Time Machine. Ничего не перемещается, пока вы не выберете."; +"Scan" = "Сканировать"; +"Nothing to clean up." = "Нечего очищать."; +"Files go to the Trash, so they aren't gone until you empty it. Simulators and snapshots are deleted right away." = "Файлы перемещаются в Корзину и не удаляются окончательно, пока вы её не очистите. Симуляторы и снимки удаляются сразу."; +"Scan Again" = "Сканировать снова"; +"Nothing selected" = "Ничего не выбрано"; +"Selected: %@" = "Выбрано: %@"; +"Move to Trash…" = "Переместить в Корзину…"; +"Remove…" = "Удалить…"; +"Storage" = "Хранилище"; +"Move the selected items to the Trash?" = "Переместить выбранные объекты в Корзину?"; +"Remove the selected items?" = "Удалить выбранные объекты?"; +"Move to Trash" = "Переместить в Корзину"; +"Remove" = "Удалить"; +"Profile" = "Профиль"; +"RemoveMacAI profile" = "Профиль RemoveMacAI"; +"Apple Intelligence off" = "Apple Intelligence выключен"; +"Not installed." = "Не установлен."; +"Other changes" = "Другие изменения"; +"None." = "Нет."; +"Puts every setting back as it was before RemoveMacAI changed it, and removes the profile." = "Возвращает все настройки в состояние до изменений RemoveMacAI и удаляет профиль."; +"Undo Everything…" = "Отменить всё…"; +"Undo" = "Отменить"; +"Undo everything RemoveMacAI changed?" = "Отменить все изменения RemoveMacAI?"; +"Undo Everything" = "Отменить всё"; +"macOS asks you to remove the profile in System Settings. Deleted models download again when a feature needs them, and files in the Trash stay there." = "macOS попросит удалить профиль в Системных настройках. Удалённые модели загрузятся снова, когда понадобятся какой-либо функции, а файлы в Корзине останутся там."; +"About %@" = "О разделе «%@»"; +"Safe by design" = "Безопасно по замыслу"; +"Read the code on GitHub" = "Код на GitHub"; +"How RemoveMacAI works" = "Как работает RemoveMacAI"; +"System Settings" = "Системные настройки"; +"General" = "Основные"; +"General › Device Management" = "Основные › Управление устройством"; +"Profiles" = "Профили"; +"Downloaded" = "Загружено"; +"Select, then click −" = "Выберите и нажмите «−»"; +"Double-click to review" = "Дважды нажмите для просмотра"; +"Remove “RemoveMacAI”?" = "Удалить «RemoveMacAI»?"; +"“RemoveMacAI” wants to install" = "«RemoveMacAI» хочет установить"; +"Install…" = "Установить…"; +"System Settings, General, Device Management. Select the RemoveMacAI profile and click the minus button to remove it." = "Системные настройки, Основные, Управление устройством. Выберите профиль RemoveMacAI и нажмите кнопку «минус», чтобы удалить его."; +"System Settings, General, Device Management. Double-click the RemoveMacAI profile under Downloaded, then click Install." = "Системные настройки, Основные, Управление устройством. Дважды нажмите профиль RemoveMacAI в разделе «Загружено», затем нажмите «Установить»."; +"Take Apple Intelligence off your Mac" = "Уберите Apple Intelligence со своего Mac"; +"RemoveMacAI turns off the Apple Intelligence features you don't use, deletes the models they download, and tidies up other things macOS adds. It's free and open source." = "RemoveMacAI выключает ненужные вам функции Apple Intelligence, удаляет загружаемые ими модели и наводит порядок в других добавках macOS. Программа бесплатная, с открытым исходным кодом."; +"How it works" = "Как это работает"; +"Three steps, and you can stop at any of them." = "Три шага, и на любом из них можно остановиться."; +"Why does macOS ask for a profile?" = "Почему macOS просит установить профиль?"; +"macOS only lets these settings stay locked through a configuration profile, the same thing schools and companies use to manage their Macs. You can read what's in it before you install it." = "macOS позволяет закрепить эти настройки только с помощью профиля конфигурации — того же механизма, которым школы и компании управляют своими Mac. Перед установкой можно посмотреть, что в нём."; +"Remove it any time in System Settings › General › Device Management, or with Undo in RemoveMacAI." = "Удалить его можно в любой момент в Системных настройках › Основные › Управление устройством или через «Отменить» в RemoveMacAI."; +"Watch the demo" = "Смотреть демонстрацию"; +"Where do you want to start?" = "С чего хотите начать?"; +"You can change any of this later. Nothing changes until you review it." = "Всё это можно изменить позже. Ничего не изменится, пока вы не просмотрите изменения."; +"Most people" = "Для большинства"; +"Skip" = "Пропустить"; +"Back" = "Назад"; +"Step %lld of 3" = "Шаг %lld из 3"; +"Continue" = "Продолжить"; +"Start Choosing" = "Начать выбор"; +"Show Me the Changes" = "Показать изменения"; +"GB" = "ГБ"; +"MB" = "МБ"; +" and " = " и "; +"Its sound library stays, because %@ uses it too." = "Библиотека звуков останется, потому что её также использует %@."; +"Frees about %@" = "Освободит около %@"; +"Undo any time" = "Можно отменить в любой момент"; +"Asks for your password once" = "Один раз запросит пароль"; +"Your settings change. This takes a few seconds." = "Настройки изменятся. Это займёт несколько секунд."; +"System Settings opens so you can remove the RemoveMacAI profile. We show you where." = "Откроются Системные настройки, чтобы вы удалили профиль RemoveMacAI. Мы покажем, где он находится."; +"Settings that don't need the profile change right away." = "Настройки, которым не нужен профиль, изменятся сразу."; +"System Settings opens and you install the RemoveMacAI profile. We show you where." = "Откроются Системные настройки, и вы установите профиль RemoveMacAI. Мы покажем, где он находится."; +"The models are deleted and you get a summary." = "Модели удаляются, и вы получаете итоговый отчёт."; +"Changing settings" = "Изменение настроек"; +"Deleting Apple Intelligence models" = "Удаление моделей Apple Intelligence"; +"Undoing changes" = "Отмена изменений"; +"Select RemoveMacAI" = "Выберите RemoveMacAI"; +"It's under General › Device Management." = "Он находится в разделе «Основные» › «Управление устройством»."; +"Click the minus button, then Remove" = "Нажмите кнопку «минус», затем «Удалить»"; +"Enter your password if macOS asks." = "Введите пароль, если macOS попросит."; +"Settings that don't need the profile are done" = "Настройки, которым не нужен профиль, уже изменены"; +"Double-click RemoveMacAI under Downloaded" = "Дважды нажмите RemoveMacAI в разделе «Загружено»"; +"It's in General › Device Management." = "Он находится в разделе «Основные» › «Управление устройством»."; +"Click Install, then enter your password" = "Нажмите «Установить» и введите пароль"; +"macOS may call the profile unsigned. That's normal for a free app that isn't from a company." = "macOS может назвать профиль неподписанным. Это нормально для бесплатной программы, которую выпускает не компания."; +"If System Settings says the profile couldn't be removed, click Cancel here. Your Mac stays as it was, and you can report it on GitHub with Help › Report a Problem." = "Если Системные настройки сообщат, что профиль не удалось удалить, нажмите здесь «Отменить». Ваш Mac останется в прежнем состоянии, а о проблеме можно сообщить на GitHub через меню «Справка» › «Сообщить о проблеме»."; +"If System Settings says the profile couldn't be installed, click Cancel here. Your Mac stays as it was, and you can report it on GitHub with Help › Report a Problem." = "Если Системные настройки сообщат, что профиль не удалось установить, нажмите здесь «Отменить». Ваш Mac останется в прежнем состоянии, а о проблеме можно сообщить на GitHub через меню «Справка» › «Сообщить о проблеме»."; +"Settings changed" = "Изменено настроек"; +"See which ones" = "Посмотреть какие"; +"Browse settings" = "Просмотреть настройки"; +"Apps running in the background" = "Программы, работающие в фоне"; +"See what they are" = "Посмотреть, что это"; +"Space you can get back" = "Место, которое можно освободить"; +"Old installers and caches" = "Старые установщики и кэши"; +"Check now" = "Проверить сейчас"; +"None" = "Нет"; +"Not checked" = "Не проверено"; +"A preset adds to what's already applied and changes nothing until you review it." = "Набор добавляется к уже применённым настройкам и ничего не меняет, пока вы его не просмотрите."; +"Checking how much space its models take…" = "Проверка, сколько места занимают модели…"; +"%@ of models are still on disk. macOS won't download more." = "На диске всё ещё %@ моделей. macOS больше не будет их загружать."; +"1 feature is kept on. Everything else is off and its models are gone." = "Одна функция оставлена включённой. Всё остальное выключено, модели удалены."; +"Its models are gone and macOS won't download them again." = "Модели удалены, и macOS не будет загружать их снова."; +"Its models take %@ on this Mac, whether you use them or not." = "Модели занимают %@ на этом Mac, пользуетесь вы ими или нет."; +"Its features are on. RemoveMacAI can turn off the ones you don't use." = "Функции включены. RemoveMacAI может выключить те, которыми вы не пользуетесь."; +"macOS asks you to install a configuration profile for this, and Undo puts everything back. This part builds on pared by 4evy, which first mapped Apple's asset service and model sets." = "Для этого macOS попросит установить профиль конфигурации, а «Отменить» вернёт всё как было. Эта часть основана на pared от 4evy — проекте, который первым описал сервис ресурсов Apple и наборы моделей."; +"A kept feature keeps the models it needs, so less space comes back." = "Оставленная функция сохраняет нужные ей модели, поэтому освободится меньше места."; +"macOS deletes the files on its own schedule, so Storage settings can count them for a while after removal." = "macOS удаляет файлы по своему расписанию, поэтому настройки хранилища могут ещё какое-то время учитывать их после удаления."; +"Settings with a lock are kept in place by the RemoveMacAI profile, which you install in System Settings." = "Настройки с замком закреплены профилем RemoveMacAI, который вы устанавливаете в Системных настройках."; +"Items marked All users ask for your password. Apple's own background services are protected by System Integrity Protection, so RemoveMacAI leaves them alone." = "Для объектов с пометкой «Все пользователи» потребуется пароль. Собственные фоновые службы Apple защищены функцией «Защита целостности системы», поэтому RemoveMacAI их не трогает."; +"Configuration profile" = "Профиль конфигурации"; +"macOS only lets these settings stay locked through a configuration profile, the same thing schools and companies use to manage Macs. RemoveMacAI shows you what's in it, and you install it yourself in System Settings. Remove it there any time, or with Undo." = "macOS позволяет закрепить эти настройки только с помощью профиля конфигурации — того же механизма, которым школы и компании управляют компьютерами Mac. RemoveMacAI показывает, что в нём, а устанавливаете его вы сами в Системных настройках. Удалить его можно там же в любой момент или через «Отменить»."; +"Apple Intelligence models" = "Модели Apple Intelligence"; +"The files Apple Intelligence runs on, downloaded by macOS whether you use the features or not. Deleting them frees the space. If you turn a feature back on, macOS downloads what it needs again." = "Файлы, на которых работает Apple Intelligence. macOS загружает их, пользуетесь вы функциями или нет. Их удаление освобождает место. Если снова включить функцию, macOS заново загрузит всё необходимое."; +"Kept off by the profile" = "Выключено профилем"; +"This setting is held off by the RemoveMacAI profile, so a macOS update or a stray click can't turn it back on. Undo removes the profile and gives you the switch back." = "Эта настройка удерживается выключенной профилем RemoveMacAI, поэтому обновление macOS или случайный щелчок не включат её снова. «Отменить» удаляет профиль и возвращает вам переключатель."; +"Siri and Siri AI" = "Siri и ИИ Siri"; +"ChatGPT and other AI extensions" = "ChatGPT и другие ИИ-расширения"; +"Writing Tools" = "Инструменты для текста"; +"Genmoji" = "Genmoji"; +"Image Playground" = "Image Playground"; +"Mail summaries and smart replies" = "Сводки и умные ответы в Почте"; +"Notification summaries" = "Сводки уведомлений"; +"Messages summaries" = "Сводки в Сообщениях"; +"Safari summaries" = "Сводки в Safari"; +"Notes transcription summaries" = "Сводки транскрипций в Заметках"; +"Inline text predictions" = "Встроенные подсказки текста"; +"Spatial Photos" = "Пространственные фото"; +"Photos Clean Up" = "«Очистка» в Фото"; +"Xcode predictive code completion" = "Предиктивное автодополнение кода в Xcode"; +"The voice assistant. Keep it and basic Siri still works without the big models." = "Голосовой ассистент. Если оставить, базовые функции Siri будут работать и без больших моделей."; +"Lets Siri and Writing Tools hand requests to ChatGPT." = "Позволяет Siri и инструментам для текста передавать запросы в ChatGPT."; +"Rewrites, proofreads and summarizes text you select." = "Переписывает, проверяет и кратко пересказывает выделенный текст."; +"Makes new emoji from a description." = "Создаёт новые эмодзи по описанию."; +"Generates pictures from a prompt." = "Создаёт изображения по запросу."; +"Summarizes emails and suggests replies in Mail." = "Кратко пересказывает письма и предлагает ответы в Почте."; +"Condenses a stack of notifications into one line." = "Сворачивает стопку уведомлений в одну строку."; +"Summarizes long conversations in Messages." = "Кратко пересказывает длинные переписки в Сообщениях."; +"Summarizes web pages in Safari." = "Кратко пересказывает веб-страницы в Safari."; +"Summarizes recordings and transcripts in Notes." = "Кратко пересказывает записи и транскрипции в Заметках."; +"Suggests the rest of a word or sentence as you type." = "Предлагает окончание слова или предложения во время ввода."; +"Turns flat photos into 3D spatial scenes." = "Превращает обычные фото в объёмные пространственные сцены."; +"Removes objects from photos in Photos." = "Удаляет объекты с фотографий в программе «Фото»."; +"Predicts code as you type in Xcode." = "Предугадывает код во время ввода в Xcode."; +"· Off, kept off by the profile" = "· Выкл., закреплено профилем"; +"· Off" = "· Выкл."; +"· On" = "· Вкл."; +"· Checking…" = "· Проверка…"; +"Apple Intelligence foundation models" = "Базовые модели Apple Intelligence"; +"Image and Genmoji models" = "Модели изображений и Genmoji"; +"Spatial Photos models" = "Модели пространственных фото"; +"Photos Clean Up models" = "Модели «Очистки» в Фото"; +"Xcode code completion models" = "Модели автодополнения кода Xcode"; +"Privacy" = "Приватность"; +"Annoyances" = "Раздражители"; +"Apple Apps" = "Программы Apple"; +"Finder" = "Finder"; +"Dock and Windows" = "Dock и окна"; +"Typing" = "Ввод текста"; +"Stop your Mac sending analytics, searches and recordings to Apple." = "Запретите Mac отправлять в Apple аналитику, поисковые запросы и записи."; +"Turn off the pop-ups, widgets and surprises nobody asked for." = "Отключите всплывающие окна, виджеты и сюрпризы, о которых никто не просил."; +"Quiet the Apple apps you don't use. macOS won't let anyone delete them." = "Утихомирьте программы Apple, которыми не пользуетесь. Удалить их macOS не позволяет никому."; +"Show what Finder hides and stop the clutter it leaves behind." = "Покажите то, что скрывает Finder, и избавьтесь от мусора, который он оставляет."; +"A faster Dock without the extra icons and animations." = "Более быстрый Dock без лишних значков и анимаций."; +"Stop macOS rewriting what you type." = "Запретите macOS исправлять то, что вы вводите."; +"Recommended" = "Рекомендуемый"; +"Maximum privacy" = "Максимальная конфиденциальность"; +"Apple Intelligence off, no analytics or ads, fewer pop-ups. Nothing you rely on stops working." = "Apple Intelligence выключен, без аналитики и рекламы, меньше всплывающих окон. Всё, на что вы полагаетесь, продолжит работать."; +"Everything in Recommended, plus Game Center and Safari's search suggestions off." = "Всё из рекомендуемого набора, плюс выключены Game Center и поисковые подсказки Safari."; +"Applies on review" = "Применится после просмотра"; +"Undoes on review" = "Отменится после просмотра"; +"Not an updater. Turning it off can stop %@ working." = "Это не программа обновления. Если выключить этот объект, %@ может перестать работать."; +"Turn off Mac Analytics" = "Выключить аналитику Mac"; +"Stops your Mac sending diagnostic and usage data to Apple and to app developers." = "Mac перестанет отправлять данные диагностики и использования в Apple и разработчикам программ."; +"Turn off personalized ads" = "Выключить персонализированную рекламу"; +"Stops Apple using what you do in its apps to target ads in the App Store, News and Stocks." = "Apple перестанет использовать ваши действия в своих программах для подбора рекламы в App Store, News и Акциях."; +"Turn off Improve Siri and Dictation" = "Выключить «Улучшение Siri и Диктовки»"; +"Stops Apple storing and reviewing recordings of your Siri and dictation requests." = "Apple перестанет хранить и прослушивать записи ваших запросов к Siri и диктовки."; +"Turn off Improve Search" = "Выключить «Улучшение Поиска»"; +"Stops Apple keeping your Spotlight, Safari and Look Up searches to improve search." = "Apple перестанет сохранять ваши запросы в Spotlight, Safari и «Найти» для улучшения поиска."; +"Turn off Spotlight internet results" = "Выключить интернет-результаты Spotlight"; +"Keeps Spotlight searches on your Mac instead of sending them to Apple for web suggestions." = "Запросы Spotlight остаются на вашем Mac и не отправляются в Apple за веб-предложениями."; +"Turn off Look Up suggestions" = "Выключить предложения «Найти»"; +"Stops Look Up sending the words you select to Apple for web results." = "«Найти» перестанет отправлять выделенные слова в Apple за результатами из интернета."; +"Turn off Safari search suggestions" = "Выключить поисковые подсказки Safari"; +"Stops Safari sending what you type in the address bar to your search engine and to Apple." = "Safari перестанет отправлять введённое в адресной строке поисковой системе и Apple."; +"The address bar no longer suggests searches as you type." = "Адресная строка больше не будет предлагать поисковые запросы во время ввода."; +"Turn off Game Center" = "Выключить Game Center"; +"Turns Game Center off, with its sign-in prompts, friend requests and notifications." = "Выключает Game Center вместе с запросами на вход, заявками в друзья и уведомлениями."; +"Games that need Game Center to save progress or play online lose that." = "Игры, которым Game Center нужен для сохранения прогресса или игры по сети, лишатся этого."; +"Stop clicks on the wallpaper hiding windows" = "Не скрывать окна при нажатии на обои"; +"Stops every window sliding away when you click the wallpaper by mistake." = "Окна больше не будут разъезжаться, если случайно нажать на обои."; +"Hide desktop widgets" = "Скрыть виджеты на рабочем столе"; +"Hides widgets on the desktop, including in Stage Manager." = "Скрывает виджеты на рабочем столе, в том числе в Stage Manager."; +"Turn off the play, pause and skip keys" = "Выключить клавиши воспроизведения, паузы и перемотки"; +"Turns off the service behind the media keys, so the play key can't open Music." = "Выключает службу медиаклавиш, чтобы клавиша воспроизведения не открывала Музыку."; +"The keys stop working in every app, including VLC and Spotify. To keep them and only stop Music opening, use Block Music launches instead." = "Клавиши перестанут работать во всех программах, включая VLC и Spotify. Чтобы оставить их и только запретить открытие Музыки, используйте вместо этого «Блокировать запуск Музыки»."; +"Block Music launches, including from AirPods" = "Блокировать запуск Музыки, в том числе с AirPods"; +"Disables Music's application launch entry, so an AirPod press no longer opens it. No background helper or configuration profile is installed." = "Отключает запись запуска программы «Музыка», чтобы нажатие на AirPods больше её не открывало. Фоновый помощник и профиль конфигурации не устанавливаются."; +"Closes Music and blocks manual launches too. Reapply after a macOS update replaces Music's files." = "Также закрывает Музыку и блокирует её ручной запуск. Примените заново после того, как обновление macOS заменит файлы Музыки."; +"Turn off iPhone Mirroring" = "Выключить «Повтор экрана iPhone»"; +"Turns iPhone Mirroring off, so the app and its prompts go away." = "Выключает «Повтор экрана iPhone», и программа вместе с её предложениями исчезает."; +"Turn off Apple Music in the Music app" = "Выключить Apple Music в программе «Музыка»"; +"Turns the Music app back into a plain player for your own library, without the store and subscription prompts." = "Превращает «Музыку» в обычный проигрыватель вашей медиатеки — без магазина и предложений подписки."; +"Apple Music subscribers lose streaming." = "Подписчики Apple Music лишатся потоковой музыки."; +"Turn off the Book Store" = "Выключить Магазин книг"; +"Removes the store from the Books app and keeps your own books." = "Убирает магазин из программы «Книги» и сохраняет ваши собственные книги."; +"Remove unused Apple apps from the Dock" = "Убрать неиспользуемые программы Apple из Dock"; +"Removes News, TV, Music, Podcasts, Books, Freeform, Image Playground, Journal, Maps and Stocks from the Dock. The apps stay installed." = "Убирает из Dock News, TV, Музыку, Подкасты, Книги, Freeform, Image Playground, Дневник, Карты и Акции. Сами программы остаются установленными."; +"Show file extensions" = "Показывать расширения файлов"; +"Shows the full name of every file, so a file pretending to be a document is easier to spot." = "Показывает полное имя каждого файла, чтобы было проще заметить файл, который выдаёт себя за документ."; +"Show hidden files" = "Показывать скрытые файлы"; +"Shows the files macOS hides, such as .gitignore and .zshrc. Press Command-Shift-Period to toggle it too." = "Показывает файлы, которые скрывает macOS, например .gitignore и .zshrc. Переключать можно также сочетанием Command-Shift-точка."; +"Show the path bar" = "Показывать строку пути"; +"Shows where the current folder is at the bottom of every Finder window." = "Показывает расположение текущей папки внизу каждого окна Finder."; +"Show the status bar" = "Показывать строку состояния"; +"Shows the number of items and the free space at the bottom of Finder windows." = "Показывает количество объектов и свободное место внизу окон Finder."; +"Keep folders above files" = "Папки над файлами"; +"Keeps folders above files when sorting by name." = "При сортировке по имени папки располагаются над файлами."; +"Search the current folder" = "Искать в текущей папке"; +"Makes Finder search the folder you're in, not the whole Mac." = "Finder будет искать в открытой папке, а не по всему Mac."; +"Skip the extension change warning" = "Не предупреждать о смене расширения"; +"Stops Finder asking for confirmation each time you change a file's extension." = "Finder перестанет запрашивать подтверждение при каждой смене расширения файла."; +"Show the full path in the title bar" = "Показывать полный путь в заголовке"; +"Shows the full folder path at the top of Finder windows." = "Показывает полный путь к папке вверху окон Finder."; +"Stop .DS_Store files on network drives" = "Не создавать .DS_Store на сетевых дисках"; +"Stops Finder leaving .DS_Store files in shared folders on other computers." = "Finder перестанет оставлять файлы .DS_Store в общих папках на других компьютерах."; +"Empty the Trash after 30 days" = "Очищать Корзину через 30 дней"; +"Deletes items that have been in the Trash for 30 days, so it stops filling the disk." = "Удаляет объекты, пролежавшие в Корзине 30 дней, чтобы она не заполняла диск."; +"Save new documents on this Mac" = "Сохранять новые документы на этом Mac"; +"Makes Save dialogs start on your Mac instead of iCloud Drive." = "Окна сохранения будут открываться на вашем Mac, а не в iCloud Drive."; +"Expand Save dialogs" = "Развёрнутые окна сохранения"; +"Opens Save dialogs with the full folder browser instead of the small version." = "Открывает окна сохранения с полным обзором папок вместо компактного вида."; +"Show the Library folder" = "Показывать папку «Библиотеки»"; +"Shows the hidden Library folder in your home folder." = "Показывает скрытую папку «Библиотеки» в вашей папке пользователя."; +"Hide recent apps in the Dock" = "Скрыть недавние программы в Dock"; +"Stops the Dock adding apps you opened recently." = "Dock перестанет добавлять недавно открытые программы."; +"Remove the Dock auto-hide delay" = "Убрать задержку автоскрытия Dock"; +"Shows a hidden Dock straight away instead of after half a second." = "Скрытый Dock появляется сразу, а не через полсекунды."; +"Stop Dock icons bouncing" = "Не подпрыгивать значкам в Dock"; +"Stops Dock icons bouncing while apps open." = "Значки в Dock перестанут подпрыгивать при открытии программ."; +"Minimize windows into their app icon" = "Убирать окна в значок программы"; +"Keeps minimized windows inside their app's icon instead of filling the Dock." = "Свёрнутые окна убираются в значок программы, а не заполняют Dock."; +"Turn off window opening animations" = "Выключить анимацию открытия окон"; +"Opens new windows instantly instead of zooming them in." = "Новые окна открываются мгновенно, без анимации увеличения."; +"Remove gaps between tiled windows" = "Убрать промежутки между окнами в мозаике"; +"Removes the margins around windows you tile to the sides of the screen." = "Убирает поля вокруг окон, расположенных мозаикой по краям экрана."; +"Remove shadows from window screenshots" = "Убрать тени со снимков окон"; +"Takes window screenshots without the large drop shadow around them." = "Снимки экрана окон делаются без большой тени вокруг."; +"Turn off autocorrect" = "Выключить автокоррекцию"; +"Stops macOS correcting words as you type." = "macOS перестанет исправлять слова во время ввода."; +"Turn off smart quotes and dashes" = "Выключить смарт-кавычки и тире"; +"Keeps straight quotes and double hyphens as you typed them, which code and terminals need." = "Прямые кавычки и двойные дефисы остаются как введены — это нужно для кода и терминала."; +"Turn off period on double space" = "Выключить точку по двойному пробелу"; +"Stops two spaces turning into a period." = "Два пробела больше не превращаются в точку."; +"Turn off automatic capitals" = "Выключить автоматические заглавные"; +"Stops macOS capitalizing the first word of each sentence." = "macOS перестанет делать заглавной первую букву каждого предложения."; +"Repeat held keys instead of showing accents" = "Повтор клавиш вместо выбора диакритики"; +"Repeats a key while you hold it, instead of opening the accent menu." = "При удержании клавиши символ повторяется, а не открывается меню диакритических знаков."; +"Type accents with Option shortcuts or the Character Viewer instead." = "Вместо этого вводите диакритику сочетаниями с Option или через «Просмотр символов»."; +"Aerial wallpaper videos" = "Видео обоев Aerial"; +"Videos for the moving aerial wallpapers and screen savers. macOS downloads one again when you choose it." = "Видео для анимированных обоев и заставок Aerial. macOS снова загрузит видео, когда вы его выберете."; +"You use an aerial wallpaper or screen saver now, so macOS will download it again." = "Сейчас вы используете обои или заставку Aerial, поэтому macOS загрузит их снова."; +"macOS installers" = "Установщики macOS"; +"Old \"Install macOS\" apps left in Applications after an upgrade." = "Старые программы «Установка macOS», оставшиеся в папке «Программы» после обновления."; +"iPhone and iPad updates" = "Обновления iPhone и iPad"; +"Software update files Finder downloaded for iPhones and iPads. They download again when needed." = "Файлы обновлений ПО, загруженные Finder для iPhone и iPad. При необходимости они загрузятся снова."; +"Xcode build data" = "Данные сборки Xcode"; +"Xcode's DerivedData folder. Xcode rebuilds it on the next build." = "Папка DerivedData Xcode. Xcode создаст её заново при следующей сборке."; +"Xcode device support" = "Поддержка устройств Xcode"; +"Debug symbols Xcode copied from each iPhone, iPad and Watch you connected. They copy again on the next connection." = "Отладочные символы, скопированные Xcode с каждого подключавшегося iPhone, iPad и Watch. Они скопируются снова при следующем подключении."; +"Unavailable simulators" = "Недоступные симуляторы"; +"Simulators for runtimes that are no longer installed, so they can't run." = "Симуляторы для сред выполнения, которые больше не установлены, поэтому запустить их нельзя."; +"Deleted right away, not moved to the Trash." = "Удаляются сразу, без перемещения в Корзину."; +"App caches" = "Кэши программ"; +"Files apps keep to load faster. Apps rebuild them, so the first launch afterwards can be slower." = "Файлы, которые программы хранят для быстрой загрузки. Программы создадут их заново, поэтому первый запуск после очистки может быть медленнее."; +"Quit your apps first. The macOS caches RemoveMacAI knows about are left alone." = "Сначала закройте программы. Известные RemoveMacAI кэши macOS не затрагиваются."; +"One of Apple's optional apps. It reinstalls free from the App Store." = "Одна из необязательных программ Apple. Её можно бесплатно установить снова из App Store."; +"Time Machine local snapshots" = "Локальные снимки Time Machine"; +"Hourly copies Time Machine keeps on this disk between backups. macOS counts them as System Data." = "Ежечасные копии, которые Time Machine хранит на этом диске между резервными копированиями. macOS учитывает их как «Системные данные»."; +"Deleted right away, not moved to the Trash. Your backups on the backup disk aren't touched." = "Удаляются сразу, без перемещения в Корзину. Резервные копии на диске резервного копирования не затрагиваются."; +"1 snapshot" = "1 снимок"; +"About %@. Items only an administrator can move ask for your password." = "Около %@. Для объектов, которые может переместить только администратор, потребуется пароль."; +"%@ are deleted right away, not moved to the Trash." = "%@: удаляются сразу, без перемещения в Корзину."; +"Installed" = "Установлен"; +"Background item: %@" = "Фоновый объект: %@"; +"You see every change first" = "Сначала вы видите каждое изменение"; +"Nothing happens until you review the list and click Apply." = "Ничего не происходит, пока вы не просмотрите список и не нажмёте «Применить»."; +"Everything can be undone" = "Всё можно отменить"; +"Undo puts every setting back. Deleted models download again when a feature needs them." = "«Отменить» возвращает все настройки. Удалённые модели загрузятся снова, когда понадобятся какой-либо функции."; +"Your Mac stays protected" = "Ваш Mac остаётся защищённым"; +"System Integrity Protection stays on. Nothing inside /System is touched." = "Защита целостности системы остаётся включённой. Ничего внутри /System не затрагивается."; +"Nothing leaves your Mac" = "Ничего не покидает ваш Mac"; +"No account, no analytics, no network requests. The code is public on GitHub." = "Без учётной записи, без аналитики, без сетевых запросов. Код открыт на GitHub."; +"You review every change before it happens" = "Вы просматриваете каждое изменение заранее"; +"Undo puts everything back" = "«Отменить» возвращает всё как было"; +"System Integrity Protection stays on" = "Защита целостности системы остаётся включённой"; +"No network requests, no analytics" = "Без сетевых запросов и аналитики"; +"Wi-Fi" = "Wi-Fi"; +"Bluetooth" = "Bluetooth"; +"Network" = "Сеть"; +"Appearance" = "Оформление"; +"Privacy & Security" = "Конфиденциальность и безопасность"; +"I'll choose myself" = "Выберу сам"; +"Start with nothing selected and pick from the sidebar." = "Начать без выбранных настроек и выбрать всё в боковом меню."; +" and %@ of models" = " и %@ моделей"; +"Nothing is selected for you" = "Ничего не выбрано заранее"; +"Choose" = "Выбор"; +"Pick a preset or switch single things off. Each one says what it does." = "Выберите готовый набор или выключайте отдельные пункты. У каждого есть описание."; +"Review" = "Просмотр"; +"You see the full list and how much space comes back. Nothing has changed yet." = "Вы видите полный список и сколько места освободится. Пока ничего не изменено."; +"Approve" = "Подтверждение"; +"macOS asks you to install a profile in System Settings. Only you can make that click." = "macOS попросит установить профиль в Системных настройках. Сделать этот щелчок можете только вы."; +"Debloats macOS and can undo every change.\n\nThe Apple Intelligence part builds on pared by 4evy, which first mapped Apple's asset service and model sets." = "Очищает macOS от лишнего и может отменить любое изменение.\n\nЧасть, связанная с Apple Intelligence, основана на pared от 4evy — проекте, который первым описал сервис ресурсов Apple и наборы моделей."; +"Could not write the profile: %@" = "Не удалось записать профиль: %@"; +"The profile is still installed. Remove RemoveMacAI in System Settings > General > Device Management." = "Профиль всё ещё установлен. Удалите RemoveMacAI в Системных настройках > Основные > Управление устройством."; +"The profile wasn't approved, so the locked settings and Apple Intelligence didn't change." = "Профиль не был подтверждён, поэтому закреплённые настройки и Apple Intelligence не изменились."; +"Apple's asset service didn't answer, so the models stay for now." = "Сервис ресурсов Apple не ответил, поэтому модели пока остаются."; +"%@ stayed: %@" = "%1$@ остались: %2$@"; +"not found on this version of macOS, so it was left alone" = "не найдены в этой версии macOS, поэтому не тронуты"; +"Removal couldn't be confirmed yet. Check again in a minute." = "Удаление пока не подтверждено. Проверьте снова через минуту."; +"Moved %@ to the Trash. Empty the Trash to free the space." = "В Корзину перемещено %@. Очистите Корзину, чтобы освободить место."; +"Deleted %@." = "Удалено: %@."; +"1 item macOS protects stayed where it was." = "1 объект, защищённый macOS, остался на месте."; +"RemoveMacAI needs your password to change background items for all users." = "RemoveMacAI требуется ваш пароль, чтобы изменить фоновые объекты для всех пользователей."; +"RemoveMacAI needs your password to move items only an administrator can change." = "RemoveMacAI требуется ваш пароль, чтобы переместить объекты, которые может изменять только администратор."; +"Turn off everything except 1 kept feature" = "Выключить всё, кроме 1 оставленной функции"; diff --git a/Localization/ru.lproj/Localizable.stringsdict b/Localization/ru.lproj/Localizable.stringsdict new file mode 100644 index 0000000..5427b87 --- /dev/null +++ b/Localization/ru.lproj/Localizable.stringsdict @@ -0,0 +1,146 @@ + + + + + %lld changes ready + + NSStringLocalizedFormatKey + %#@n@ + n + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + few + Готово %lld изменения + many + Готово %lld изменений + one + Готово %lld изменение + other + Готово %lld изменения + + + %lld features are kept on. Everything else is off and its models are gone. + + NSStringLocalizedFormatKey + %#@n@ + n + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + few + %lld функции оставлены включёнными. Всё остальное выключено, модели удалены. + many + %lld функций оставлено включёнными. Всё остальное выключено, модели удалены. + one + %lld функция оставлена включённой. Всё остальное выключено, модели удалены. + other + %lld функции оставлены включёнными. Всё остальное выключено, модели удалены. + + + %lld items macOS protects stayed where they were. + + NSStringLocalizedFormatKey + %#@n@ + n + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + few + %lld объекта, защищённых macOS, остались на месте. + many + %lld объектов, защищённых macOS, остались на месте. + one + %lld объект, защищённый macOS, остался на месте. + other + %lld объекта, защищённых macOS, остались на месте. + + + %lld settings + + NSStringLocalizedFormatKey + %#@n@ + n + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + few + %lld настройки + many + %lld настроек + one + %lld настройка + other + %lld настройки + + + %lld settings and Apple Intelligence off + + NSStringLocalizedFormatKey + %#@n@ + n + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + few + %lld настройки и выключение Apple Intelligence + many + %lld настроек и выключение Apple Intelligence + one + %lld настройка и выключение Apple Intelligence + other + %lld настройки и выключение Apple Intelligence + + + %lld snapshots + + NSStringLocalizedFormatKey + %#@n@ + n + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + few + %lld снимка + many + %lld снимков + one + %lld снимок + other + %lld снимка + + + Turn off everything except %lld kept features + + NSStringLocalizedFormatKey + %#@n@ + n + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + few + Выключить всё, кроме %lld оставленных функций + many + Выключить всё, кроме %lld оставленных функций + one + Выключить всё, кроме %lld оставленной функции + other + Выключить всё, кроме %lld оставленных функций + + + + diff --git a/Sources/removemacai/App/AppModel.swift b/Sources/removemacai/App/AppModel.swift index f0bff6b..ad52c08 100644 --- a/Sources/removemacai/App/AppModel.swift +++ b/Sources/removemacai/App/AppModel.swift @@ -213,7 +213,7 @@ final class AppModel { if let target = plan.profile { await MainActor.run { self.run = .approveProfile(remove: target.isEmpty) } if !target.isEmpty { - do { try Profile.present(target) } catch { problems.append("Could not write the profile: \(error)") } + do { try Profile.present(target) } catch { problems.append(String(localized: "Could not write the profile: \(String(describing: error))")) } try? await Task.sleep(nanoseconds: 1_000_000_000) } await MainActor.run { Commands.openProfileSettings() } @@ -221,8 +221,8 @@ final class AppModel { if !approved { problems.append( target.isEmpty - ? "The profile is still installed. Remove RemoveMacAI in System Settings > General > Device Management." - : "The profile wasn't approved, so the locked settings and Apple Intelligence didn't change.") + ? String(localized: "The profile is still installed. Remove RemoveMacAI in System Settings > General > Device Management.") + : String(localized: "The profile wasn't approved, so the locked settings and Apple Intelligence didn't change.")) } } // Models only go once the profile blocks their download. @@ -241,19 +241,19 @@ final class AppModel { } nonisolated static func deleteModels(_ sets: [String]) -> (problems: [String], freed: Int64?) { - guard Models.available() else { return (["Apple's asset service didn't answer, so the models stay for now."], nil) } + guard Models.available() else { return ([String(localized: "Apple's asset service didn't answer, so the models stay for now.")], nil) } var problems: [String] = [] var removing = sets do { let split = try Models.matching(sets) removing = split.matched - problems += split.skipped.map { "\(Catalog.modelSet($0.0)?.title ?? $0.0) stayed: \($0.1)" } + problems += split.skipped.map { String(localized: "\(L(Catalog.modelSet($0.0)?.title ?? $0.0)) stayed: \(L($0.1))") } } catch { return (["\(error)"], nil) } let before = Models.total(removing) do { let result = try Commands.removeModels(removing, before: before) - problems += result.failures.map { "\(Catalog.modelSet($0.0)?.title ?? $0.0): \($0.1)" } - if !result.verified { problems.append("Removal couldn't be confirmed yet. Check again in a minute.") } + problems += result.failures.map { "\(L(Catalog.modelSet($0.0)?.title ?? $0.0)): \(L($0.1))" } + if !result.verified { problems.append(String(localized: "Removal couldn't be confirmed yet. Check again in a minute.")) } return (problems, result.deletedBytes) } catch { return (problems + ["\(error)"], nil) @@ -303,7 +303,7 @@ final class AppModel { Commands.openProfileSettings() } if !(await self.waitFor { !Profile.installed().on }) { - problems.append("The profile is still installed. Remove RemoveMacAI in System Settings > General > Device Management.") + problems.append(String(localized: "The profile is still installed. Remove RemoveMacAI in System Settings > General > Device Management.")) } } let outcome = RunState.finished(problems: problems, freed: nil) @@ -357,14 +357,16 @@ final class AppModel { let trashedTotal = chosen.filter { !$0.permanent }.reduce(Int64(0)) { $0 + $1.bytes } let left = items.filter { trashedIDs.contains($0.id) }.reduce(Int64(0)) { $0 + $1.bytes } var parts: [String] = [] - if !trashedIDs.isEmpty { parts.append("Moved \(Term.size(max(0, trashedTotal - left))) to the Trash. Empty the Trash to free the space.") } + if !trashedIDs.isEmpty { parts.append(String(localized: "Moved \(UISize.of(max(0, trashedTotal - left))) to the Trash. Empty the Trash to free the space.")) } let permanent = chosen.filter(\.permanent) if !permanent.isEmpty && result.problems.isEmpty { - parts.append("Deleted \(permanent.map(\.title).joined(separator: " and ")).") + parts.append(String(localized: "Deleted \(permanent.map { L($0.title) }.joined(separator: L(" and "))).")) } var message = parts.joined(separator: " ") if result.protected > 0 { - message += " \(result.protected) item\(result.protected == 1 ? "" : "s") macOS protects stayed where \(result.protected == 1 ? "it was" : "they were")." + message += " " + (result.protected == 1 + ? String(localized: "1 item macOS protects stayed where it was.") + : String(localized: "\(result.protected) items macOS protects stayed where they were.")) } if !result.problems.isEmpty { message += "\n" + result.problems.joined(separator: "\n") } let finalMessage = message diff --git a/Sources/removemacai/App/Help.swift b/Sources/removemacai/App/Help.swift index 8f7879d..87e5f8f 100644 --- a/Sources/removemacai/App/Help.swift +++ b/Sources/removemacai/App/Help.swift @@ -13,12 +13,12 @@ struct InfoButton: View { Image(systemName: "info.circle").foregroundStyle(.secondary) } .buttonStyle(.plain) - .help(title) - .accessibilityLabel("About \(title)") + .help(L(title)) + .accessibilityLabel("About \(L(title))") .popover(isPresented: $shown, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 6) { - Text(title).font(.headline) - Text(text).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(L(title)).font(.headline) + Text(L(text)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) } .padding(14) .frame(width: 300, alignment: .leading) @@ -88,8 +88,8 @@ struct PromiseCard: View { .foregroundStyle(promise.tint) .frame(width: 28) VStack(alignment: .leading, spacing: 3) { - Text(promise.title).font(.headline) - Text(promise.detail).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(L(promise.title)).font(.headline) + Text(L(promise.detail)).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } @@ -128,7 +128,7 @@ struct SafetyCard: View { } } - func check(_ text: String) -> some View { + func check(_ text: LocalizedStringKey) -> some View { Label { Text(text).foregroundStyle(.primary) } icon: { @@ -160,12 +160,12 @@ struct SettingsGuide: View { .background(.quaternary.opacity(0.6)) HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: 7) { - ForEach(["Wi-Fi", "Bluetooth", "Network"], id: \.self) { Text($0) } + ForEach(["Wi-Fi", "Bluetooth", "Network"], id: \.self) { Text(L($0)) } Text("General") .foregroundStyle(.white) .padding(.horizontal, 6).padding(.vertical, 2) .background(RoundedRectangle(cornerRadius: 5).fill(Color.accentColor)) - ForEach(["Appearance", "Privacy & Security"], id: \.self) { Text($0) } + ForEach(["Appearance", "Privacy & Security"], id: \.self) { Text(L($0)) } } .font(.caption) .padding(10) diff --git a/Sources/removemacai/App/L10n.swift b/Sources/removemacai/App/L10n.swift new file mode 100644 index 0000000..287abbe --- /dev/null +++ b/Sources/removemacai/App/L10n.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Looks up the translation of a fixed English string, such as a tweak's title, +/// in the app's Localization/.lproj. Gives back the English when +/// there is none, and always from the bare command-line binary. +func L(_ s: String) -> String { + guard !s.isEmpty else { return s } + return Bundle.main.localizedString(forKey: s, value: s, table: nil) +} + +/// A size for the window: the number in the region's format, the unit in the app's language. +enum UISize { + static func of(_ bytes: Int64) -> String { + let s = Term.size(bytes) + let unit: String + if s.hasSuffix(" GB") { unit = L("GB") } else if s.hasSuffix(" MB") { unit = L("MB") } else { return s } + let number = s.dropLast(3).replacingOccurrences(of: ".", with: Locale.current.decimalSeparator ?? ".") + return number + " " + unit + } +} + +/// Translates a message the engine builds from parts, such as a storage +/// caveat that names apps, and anything else with a fixed English text. +func localizedMessage(_ s: String) -> String { + let prefix = "Its sound library stays, because ", suffix = " uses it too." + if s.hasPrefix(prefix) && s.hasSuffix(suffix) { + let names = String(s.dropFirst(prefix.count).dropLast(suffix.count)) + .replacingOccurrences(of: " and ", with: L(" and ")) + return String(localized: "Its sound library stays, because \(names) uses it too.") + } + return L(s) +} diff --git a/Sources/removemacai/App/Pages.swift b/Sources/removemacai/App/Pages.swift index 72d0c46..3505b36 100644 --- a/Sources/removemacai/App/Pages.swift +++ b/Sources/removemacai/App/Pages.swift @@ -10,15 +10,15 @@ struct OverviewView: View { VStack(alignment: .leading, spacing: 18) { StatusHero() HStack(spacing: 12) { - StatTile(title: "Settings changed", value: "\(model.appliedCount) of \(model.availableCount)", link: model.appliedCount > 0 ? "See which ones" : "Browse settings") { + StatTile(title: "Settings changed", value: String(localized: "\(model.appliedCount) of \(model.availableCount)"), link: model.appliedCount > 0 ? "See which ones" : "Browse settings") { model.page = .group(TweakGroup.allCases.first { model.switchedOn(in: $0).on > 0 } ?? .privacy) } - StatTile(title: "Apps running in the background", value: model.background.isEmpty ? "None" : "\(model.background.count)", link: "See what they are") { + StatTile(title: "Apps running in the background", value: model.background.isEmpty ? L("None") : "\(model.background.count)", link: "See what they are") { model.page = .background } StatTile( title: "Space you can get back", - value: model.scanned ? Term.size(model.storage.reduce(0) { $0 + $1.bytes }) : "Not checked", + value: model.scanned ? UISize.of(model.storage.reduce(0) { $0 + $1.bytes }) : L("Not checked"), link: model.scanned ? "Old installers and caches" : "Check now" ) { model.page = .storage @@ -34,8 +34,8 @@ struct OverviewView: View { Image(systemName: preset == .recommended ? "checkmark.seal" : "hand.raised") .font(.title2).foregroundStyle(.tint).frame(width: 28) VStack(alignment: .leading, spacing: 3) { - Text(preset.title).font(.headline) - Text(preset.summary).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(L(preset.title)).font(.headline) + Text(L(preset.summary)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) Text("\(Tweaks.preset(preset).count) settings and Apple Intelligence off") .font(.caption).foregroundStyle(.tertiary) } @@ -95,7 +95,7 @@ struct StatusHero: View { if !off && model.modelTotal > 0, let disk = DiskSize.total { ProgressView(value: Double(model.modelTotal), total: Double(max(disk, model.modelTotal))) .tint(.orange) - .accessibilityLabel("\(Term.size(model.modelTotal)) of models out of \(Term.size(disk)) of storage") + .accessibilityLabel("\(UISize.of(model.modelTotal)) of models out of \(UISize.of(disk)) of storage") } HStack(spacing: 10) { if off { @@ -121,21 +121,25 @@ struct StatusHero: View { } var subtitle: String { - if model.loadingModels { return "Checking how much space its models take…" } + if model.loadingModels { return String(localized: "Checking how much space its models take…") } if off { - if model.modelTotal > 0 { return "\(Term.size(model.modelTotal)) of models are still on disk. macOS won't download more." } - if let kept = model.currentAI, !kept.isEmpty { return "\(kept.count) feature\(kept.count == 1 ? " is" : "s are") kept on. Everything else is off and its models are gone." } - return "Its models are gone and macOS won't download them again." + if model.modelTotal > 0 { return String(localized: "\(UISize.of(model.modelTotal)) of models are still on disk. macOS won't download more.") } + if let kept = model.currentAI, !kept.isEmpty { + return kept.count == 1 + ? String(localized: "1 feature is kept on. Everything else is off and its models are gone.") + : String(localized: "\(kept.count) features are kept on. Everything else is off and its models are gone.") + } + return String(localized: "Its models are gone and macOS won't download them again.") } - if model.modelTotal > 0 { return "Its models take \(Term.size(model.modelTotal)) on this Mac, whether you use them or not." } - return "Its features are on. RemoveMacAI can turn off the ones you don't use." + if model.modelTotal > 0 { return String(localized: "Its models take \(UISize.of(model.modelTotal)) on this Mac, whether you use them or not.") } + return String(localized: "Its features are on. RemoveMacAI can turn off the ones you don't use.") } } struct StatTile: View { - let title: String + let title: LocalizedStringKey let value: String - let link: String + let link: LocalizedStringKey let action: () -> Void var body: some View { @@ -202,11 +206,11 @@ struct IntelligenceView: View { )) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { - Text(feature.title) - Text(stateText(feature.id)).font(.caption).foregroundStyle(.secondary) + Text(L(feature.title)) + Text(L(stateText(feature.id))).font(.caption).foregroundStyle(.secondary) } if let blurb = FeatureBlurbs.text[feature.id] { - Text(blurb).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(L(blurb)).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) } } } @@ -229,12 +233,12 @@ struct IntelligenceView: View { Text("Apple's asset service didn't answer, so the sizes are unknown.").foregroundStyle(.secondary) } else { ForEach(Catalog.availableModelSets, id: \.name) { set in - LabeledContent(set.title) { + LabeledContent(L(set.title)) { let bytes = model.modelBytes[set.name] ?? 0 - Text(bytes > 0 ? Term.size(bytes) : "None").foregroundStyle(bytes > 0 ? .primary : .secondary) + Text(bytes > 0 ? UISize.of(bytes) : L("None")).foregroundStyle(bytes > 0 ? .primary : .secondary) } } - LabeledContent("Total") { Text(Term.size(model.modelTotal)).fontWeight(.semibold) } + LabeledContent("Total") { Text(UISize.of(model.modelTotal)).fontWeight(.semibold) } if model.currentAI != nil && model.modelTotal > 0 { HStack { Text("Apple Intelligence is off, but these models are still on disk.").foregroundStyle(.secondary) @@ -279,7 +283,7 @@ struct TweakGroupView: View { Section { ForEach(tweaks) { TweakRow(tweak: $0) } } header: { - Text(group.summary).font(.body).foregroundStyle(.secondary).textCase(nil) + Text(L(group.summary)).font(.body).foregroundStyle(.secondary).textCase(nil) } footer: { if tweaks.contains(where: \.inProfile) { HStack(alignment: .firstTextBaseline, spacing: 6) { @@ -290,7 +294,7 @@ struct TweakGroupView: View { } } .formStyle(.grouped) - .navigationTitle(group.title) + .navigationTitle(L(group.title)) .toolbar { ToolbarItem { Menu { @@ -324,18 +328,18 @@ struct TweakRow: View { Toggle(isOn: Binding(get: { wanted }, set: { model.toggle(tweak, $0) })) { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { - Text(tweak.title) + Text(L(tweak.title)) if tweak.inProfile { Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.tertiary) .help("Kept in place by the RemoveMacAI profile") } if let note = pendingNote(state: state, wanted: wanted, undoes: model.undoes(tweak)) { - Text(note).font(.caption.weight(.medium)).foregroundStyle(.tint) + Text(L(note)).font(.caption.weight(.medium)).foregroundStyle(.tint) } } - Text(tweak.detail).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(L(tweak.detail)).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) if let caveat = tweak.caveat { - Label(caveat, systemImage: "exclamationmark.triangle").font(.caption).foregroundStyle(.orange) + Label(L(caveat), systemImage: "exclamationmark.triangle").font(.caption).foregroundStyle(.orange) } if state == .managed { Text("Managed by another profile on this Mac.").font(.caption).foregroundStyle(.secondary) @@ -385,8 +389,8 @@ struct BackgroundView: View { Text(item.label).font(.caption.monospaced()).foregroundStyle(.secondary) Text(item.program).font(.caption2.monospaced()).foregroundStyle(.tertiary) .lineLimit(1).truncationMode(.middle) - if let warning = item.warning { - Label(warning, systemImage: "exclamationmark.triangle").font(.caption).foregroundStyle(.orange) + if item.warning != nil { + Label(String(localized: "Not an updater. Turning it off can stop \(item.owner) working."), systemImage: "exclamationmark.triangle").font(.caption).foregroundStyle(.orange) } } } @@ -451,14 +455,16 @@ struct StorageView: View { .toggleStyle(.checkbox) .labelsHidden() VStack(alignment: .leading, spacing: 3) { - Text(item.title) - Text(item.detail).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(L(item.title)) + Text(L(item.detail)).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) if let caveat = item.caveat { - Label(caveat, systemImage: "info.circle").font(.caption).foregroundStyle(.secondary) + Label(localizedMessage(caveat), systemImage: "info.circle").font(.caption).foregroundStyle(.secondary) } } Spacer() - Text(item.kind == .snapshots ? "\(item.count) snapshot\(item.count == 1 ? "" : "s")" : Term.size(item.bytes)) + Text(item.kind == .snapshots + ? (item.count == 1 ? L("1 snapshot") : String(localized: "\(item.count) snapshots")) + : UISize.of(item.bytes)) .monospacedDigit().foregroundStyle(.secondary) } .padding(.vertical, 2) @@ -475,7 +481,7 @@ struct StorageView: View { if let message = model.cleanMessage { Text(message).foregroundStyle(.secondary) } else { - Text(model.storageChosen.isEmpty ? "Nothing selected" : "Selected: \(Term.size(model.chosenBytes))").monospacedDigit() + Text(model.storageChosen.isEmpty ? "Nothing selected" : "Selected: \(UISize.of(model.chosenBytes))").monospacedDigit() } Spacer() if model.cleaning { ProgressView().controlSize(.small) } @@ -494,8 +500,8 @@ struct StorageView: View { ) { Button(permanentChosen.isEmpty ? "Move to Trash" : "Remove") { model.clean() } } message: { - Text("About \(Term.size(model.chosenBytes)). Items only an administrator can move ask for your password." - + (permanentChosen.isEmpty ? "" : " \(permanentChosen.joined(separator: " and ")) are deleted right away, not moved to the Trash.")) + Text(String(localized: "About \(UISize.of(model.chosenBytes)). Items only an administrator can move ask for your password.") + + (permanentChosen.isEmpty ? "" : " " + String(localized: "\(permanentChosen.map(L).joined(separator: L(" and "))) are deleted right away, not moved to the Trash."))) } } } @@ -510,10 +516,10 @@ struct ChangesView: View { Form { Section("Profile") { if model.snapshot.profile.on { - LabeledContent("RemoveMacAI profile", value: "Installed") + LabeledContent("RemoveMacAI profile", value: L("Installed")) if model.currentAI != nil { Label("Apple Intelligence off", systemImage: "apple.intelligence") } ForEach(model.snapshot.profile.tweaks.sorted(), id: \.self) { id in - Label(Tweaks.tweak(id)?.title ?? id, systemImage: "lock.fill") + Label(Tweaks.tweak(id).map { L($0.title) } ?? id, systemImage: "lock.fill") } } else { Text("Not installed.").foregroundStyle(.secondary) @@ -555,15 +561,15 @@ struct ChangesView: View { } func title(_ id: String) -> String { - if let t = Tweaks.tweak(id) { return t.title } - if let item = model.background.first(where: { $0.id == id }) { return "Background item: \(item.owner)" } + if let t = Tweaks.tweak(id) { return L(t.title) } + if let item = model.background.first(where: { $0.id == id }) { return String(localized: "Background item: \(item.owner)") } return id } } struct Footer: View { - let text: String - init(_ text: String) { self.text = text } + let text: LocalizedStringKey + init(_ text: LocalizedStringKey) { self.text = text } var body: some View { Text(text) diff --git a/Sources/removemacai/App/RemoveMacAIApp.swift b/Sources/removemacai/App/RemoveMacAIApp.swift index ad8e450..9722bf6 100644 --- a/Sources/removemacai/App/RemoveMacAIApp.swift +++ b/Sources/removemacai/App/RemoveMacAIApp.swift @@ -56,7 +56,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { static func showAbout() { let credits = NSMutableAttributedString( - string: "Debloats macOS and can undo every change.\n\nThe Apple Intelligence part builds on pared by 4evy, which first mapped Apple's asset service and model sets.", + string: String(localized: "Debloats macOS and can undo every change.\n\nThe Apple Intelligence part builds on pared by 4evy, which first mapped Apple's asset service and model sets."), attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: NSColor.secondaryLabelColor]) NSApp.orderFrontStandardAboutPanel(options: [ .applicationName: "RemoveMacAI", diff --git a/Sources/removemacai/App/Views.swift b/Sources/removemacai/App/Views.swift index e812b6b..f54db61 100644 --- a/Sources/removemacai/App/Views.swift +++ b/Sources/removemacai/App/Views.swift @@ -78,7 +78,7 @@ struct Sidebar: View { } func row(_ page: Page, badge: Text? = nil) -> some View { - Label(page.title, systemImage: page.symbol) + Label(L(page.title), systemImage: page.symbol) .badge(badge) .tag(page) } @@ -133,7 +133,7 @@ struct ReviewSheet: View { if let plan { HStack(spacing: 8) { if !plan.models.isEmpty, let bytes = model.modelBytes(plan.models), bytes > 0 { - chip("Frees about \(Term.size(bytes))", color: .green) + chip("Frees about \(UISize.of(bytes))", color: .green) } chip("Undo any time", color: .accentColor) if plan.profile.map({ !$0.isEmpty }) ?? false { @@ -152,7 +152,9 @@ struct ReviewSheet: View { Section("Apple Intelligence") { if model.aiPending, let kept = model.targetAI { Label( - kept.isEmpty ? "Turn off every feature" : "Turn off everything except \(kept.count) kept feature\(kept.count == 1 ? "" : "s")", + kept.isEmpty + ? "Turn off every feature" + : kept.count == 1 ? "Turn off everything except 1 kept feature" : "Turn off everything except \(kept.count) kept features", systemImage: "apple.intelligence") } else if model.aiPending { Label("Turn Apple Intelligence back on. macOS downloads its models again when a feature needs them.", systemImage: "arrow.uturn.backward") @@ -161,7 +163,7 @@ struct ReviewSheet: View { let bytes = model.modelBytes(plan.models) Label { HStack { - Text(bytes.map { $0 > 0 ? "Delete the models, about \(Term.size($0))" : "Ask macOS to finish removing leftover model files" } ?? "Delete the models") + Text(bytes.map { $0 > 0 ? String(localized: "Delete the models, about \(UISize.of($0))") : L("Ask macOS to finish removing leftover model files") } ?? L("Delete the models")) Spacer() Text("macOS can download them again").font(.caption).foregroundStyle(.secondary) } @@ -223,7 +225,7 @@ struct ReviewSheet: View { } } - func chip(_ text: String, color: Color) -> some View { + func chip(_ text: LocalizedStringKey, color: Color) -> some View { Text(text) .font(.caption.weight(.semibold)) .foregroundStyle(color == .secondary ? Color.secondary : color) @@ -231,7 +233,7 @@ struct ReviewSheet: View { .background(Capsule().fill(color.opacity(0.13))) } - func step(_ n: Int, _ text: String) -> some View { + func step(_ n: Int, _ text: LocalizedStringKey) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { StepBadge(number: n) Text(text).fixedSize(horizontal: false, vertical: true) @@ -249,7 +251,7 @@ struct ChangeRow: View { HStack(spacing: 8) { Image(systemName: adding ? "plus.circle.fill" : "arrow.uturn.backward.circle.fill") .foregroundStyle(adding ? Color.accentColor : .orange) - Text(tweak.title) + Text(L(tweak.title)) if tweak.inProfile { Image(systemName: "lock.fill").font(.caption).foregroundStyle(.secondary) .help("Kept in place by the RemoveMacAI profile") @@ -283,7 +285,7 @@ struct RunSheet: View { case .working(let text)?: VStack(spacing: 16) { ProgressView().controlSize(.large) - Text(text).font(.headline) + Text(L(text)).font(.headline) Text("This takes a few seconds.").foregroundStyle(.secondary) } .padding(40) @@ -349,7 +351,9 @@ struct ApproveProfileView: View { } } DisclosureGroup("Something went wrong", isExpanded: $showTrouble) { - Text("If System Settings says the profile couldn't be \(remove ? "removed" : "installed"), click Cancel here. Your Mac stays as it was, and you can report it on GitHub with Help › Report a Problem.") + Text(remove + ? "If System Settings says the profile couldn't be removed, click Cancel here. Your Mac stays as it was, and you can report it on GitHub with Help › Report a Problem." + : "If System Settings says the profile couldn't be installed, click Cancel here. Your Mac stays as it was, and you can report it on GitHub with Help › Report a Problem.") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -368,7 +372,7 @@ struct ApproveProfileView: View { .fixedSize(horizontal: false, vertical: true) } - func stepRow(_ n: Int, _ title: String, _ detail: String?, active: Bool, done: Bool = false) -> some View { + func stepRow(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey?, active: Bool, done: Bool = false) -> some View { HStack(alignment: .top, spacing: 10) { StepBadge(number: n, done: done, active: active) VStack(alignment: .leading, spacing: 2) { @@ -400,14 +404,14 @@ struct FinishedView: View { .accessibilityHidden(true) Text(problems.isEmpty ? "All done" : "Finished, with a few problems").font(.title2.weight(.bold)) if let freed, freed > 0 { - Text("Deleted \(Term.size(freed)) of Apple Intelligence models. macOS clears the files on its own schedule, so Storage settings may show the old size for a while.") + Text("Deleted \(UISize.of(freed)) of Apple Intelligence models. macOS clears the files on its own schedule, so Storage settings may show the old size for a while.") .multilineTextAlignment(.center).foregroundStyle(.secondary).frame(maxWidth: 420) .fixedSize(horizontal: false, vertical: true) } if !problems.isEmpty { ScrollView { VStack(alignment: .leading, spacing: 6) { - ForEach(problems, id: \.self) { Text("• " + $0).textSelection(.enabled) } + ForEach(problems, id: \.self) { Text("• " + localizedMessage($0)).textSelection(.enabled) } } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Sources/removemacai/App/Welcome.swift b/Sources/removemacai/App/Welcome.swift index d5fa46f..95e7684 100644 --- a/Sources/removemacai/App/Welcome.swift +++ b/Sources/removemacai/App/Welcome.swift @@ -134,30 +134,30 @@ struct WelcomeSheet: View { func title(_ option: Start) -> String { switch option { - case .recommended: return Preset.recommended.title - case .privacy: return Preset.privacy.title - case .own: return "I'll choose myself" + case .recommended: return L(Preset.recommended.title) + case .privacy: return L(Preset.privacy.title) + case .own: return String(localized: "I'll choose myself") } } func summary(_ option: Start) -> String { switch option { - case .recommended: return Preset.recommended.summary - case .privacy: return Preset.privacy.summary - case .own: return "Start with nothing selected and pick from the sidebar." + case .recommended: return L(Preset.recommended.summary) + case .privacy: return L(Preset.privacy.summary) + case .own: return String(localized: "Start with nothing selected and pick from the sidebar.") } } func detail(_ option: Start) -> String { - let models = model.modelTotal > 0 ? " and \(Term.size(model.modelTotal)) of models" : "" + let models = model.modelTotal > 0 ? String(localized: " and \(UISize.of(model.modelTotal)) of models") : "" switch option { - case .recommended: return "\(Tweaks.preset(.recommended).count) settings\(models)" - case .privacy: return "\(Tweaks.preset(.privacy).count) settings\(models)" - case .own: return "Nothing is selected for you" + case .recommended: return String(localized: "\(Tweaks.preset(.recommended).count) settings") + models + case .privacy: return String(localized: "\(Tweaks.preset(.privacy).count) settings") + models + case .own: return String(localized: "Nothing is selected for you") } } - func stepCard(_ n: Int, _ title: String, _ text: String, highlight: Bool = false) -> some View { + func stepCard(_ n: Int, _ title: LocalizedStringKey, _ text: LocalizedStringKey, highlight: Bool = false) -> some View { VStack(alignment: .leading, spacing: 6) { StepBadge(number: n) Text(title).font(.headline) diff --git a/Sources/removemacai/BackgroundItems.swift b/Sources/removemacai/BackgroundItems.swift index 12c1386..10ecea9 100644 --- a/Sources/removemacai/BackgroundItems.swift +++ b/Sources/removemacai/BackgroundItems.swift @@ -117,7 +117,7 @@ enum BackgroundItems { if !disabled { journal.entries[item.id] = nil } } if !adminCommands.isEmpty { - let result = Shell.admin(adminCommands, prompt: "RemoveMacAI needs your password to change background items for all users.") + let result = Shell.admin(adminCommands, prompt: String(localized: "RemoveMacAI needs your password to change background items for all users.")) if !result.ok { problems.append(Shell.adminError(result)) } } Engine.save(journal) diff --git a/Sources/removemacai/Storage.swift b/Sources/removemacai/Storage.swift index 3febcd1..f95171c 100644 --- a/Sources/removemacai/Storage.swift +++ b/Sources/removemacai/Storage.swift @@ -241,7 +241,7 @@ enum Storage { } } if !adminCommands.isEmpty { - let run = Shell.admin(adminCommands, prompt: "RemoveMacAI needs your password to move items only an administrator can change.") + let run = Shell.admin(adminCommands, prompt: String(localized: "RemoveMacAI needs your password to move items only an administrator can change.")) if !run.ok { result.problems.append(Shell.adminError(run)) } } return result diff --git a/tools/build-app.sh b/tools/build-app.sh index 581ad2b..4be67bc 100755 --- a/tools/build-app.sh +++ b/tools/build-app.sh @@ -17,6 +17,12 @@ mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" cp "$bin" "$app/Contents/MacOS/RemoveMacAI" cp LICENSE THIRD-PARTY-NOTICES.md "$app/Contents/Resources/" +# Translations: Localization/.lproj. English is the source language, so +# it needs no folder; the keys are the English strings in the code. +cp -R Localization/*.lproj "$app/Contents/Resources/" +languages="en" +for dir in Localization/*.lproj; do languages+="$(basename "$dir" .lproj)"; done + mkdir -p .build/AppIcon.iconset for size in 16 32 128 256 512; do sips -z $size $size tools/AppIcon.png --out ".build/AppIcon.iconset/icon_${size}x${size}.png" >/dev/null @@ -30,6 +36,7 @@ cat > "$app/Contents/Info.plist" < CFBundleDevelopmentRegionen + CFBundleLocalizations$languages CFBundleExecutableRemoveMacAI CFBundleIconFileAppIcon CFBundleIdentifierio.github.omlahore.removemacai.app diff --git a/tools/check-localization.py b/tools/check-localization.py new file mode 100755 index 0000000..130de38 --- /dev/null +++ b/tools/check-localization.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Lists English UI strings in the app that a translation doesn't cover. + + tools/check-localization.py ru + +Run from the repository root. Interpolations become %lld (counts) or %@ +(text), the way SwiftUI and String(localized:) build their keys. Exits 1 when +something is missing, so it can run in CI. +""" +import glob, plistlib, re, sys + +lang = sys.argv[1] if len(sys.argv) > 1 else "ru" +folder = f"Localization/{lang}.lproj" + +def strings_keys(path): + text = open(path, encoding="utf-8").read() + keys = set() + for m in re.finditer(r'^\s*"((?:[^"\\]|\\.)*)"\s*=', text, re.M): + keys.add(re.sub(r'\\(.)', lambda e: {"n": "\n", "t": "\t"}.get(e.group(1), e.group(1)), m.group(1))) + return keys + +keys = strings_keys(f"{folder}/Localizable.strings") +try: + keys |= set(plistlib.load(open(f"{folder}/Localizable.stringsdict", "rb"))) +except FileNotFoundError: + pass + +COUNT = re.compile(r"count|protected|\.on\b|\.total|since|Count\b|number|welcomePage") + +def literals(code): + """Yields (offset, text) for each one-line string literal.""" + i, n = 0, len(code) + while i < n: + if code.startswith("//", i): + i = code.find("\n", i) + i = n if i < 0 else i + continue + if code[i] == '"' and not code.startswith('"""', i): + start, j, out = i, i + 1, "" + while j < n and code[j] != '"': + if code.startswith("\\(", j): + depth, k = 1, j + 2 + while depth: + if code[k] == "(": depth += 1 + elif code[k] == ")": depth -= 1 + elif code[k] == '"': k = code.find('"', k + 1) + k += 1 + out += "%lld" if COUNT.search(code[j + 2:k - 1]) else "%@" + j = k + continue + if code[j] == "\\": + out += {"n": "\n", "t": "\t"}.get(code[j + 1], code[j + 1]) + j += 2 + continue + out += code[j] + j += 1 + yield start, out + i = j + 1 + continue + i += 1 + +# In the shared engine files only these fields reach the window. +DATA_FIELD = re.compile(r'(?:title|detail|caveat|prompt):\s*(?:String\(localized:\s*)?(?:[^"\n]*\?\s*)?$|case \.\w+: return $|isUpdater \? nil : $') +NOT_UI = re.compile(r'^((?:%lld|%@)+$|https?:|/|~|com\.|io\.|group\.|[a-z0-9.\-_:]+$|[A-Z_]+$|[a-z][A-Za-z]*$|[A-Z][a-z]*[A-Z]\w*$|• $|.*\.(app|plist)$)') +ENGINE = ["Tweaks.swift", "Catalog.swift", "Storage.swift", "BackgroundItems.swift"] +# Words people know by their English name, used alone. +NAMES = {"Finder", "Genmoji", "Image Playground", "Wi-Fi", "Bluetooth", "RemoveMacAI", "Apple Intelligence"} + +missing = [] +files = sorted(glob.glob("Sources/removemacai/App/*.swift")) + [f"Sources/removemacai/{f}" for f in ENGINE] +for path in files: + if path.endswith(("Snapshots.swift", "L10n.swift")): + continue + code = open(path, encoding="utf-8").read() + engine = path.split("/")[-1] in ENGINE + for start, text in literals(code): + line_start = code.rfind("\n", 0, start) + 1 + before = code[line_start:start] + if engine and not DATA_FIELD.search(before.strip() + " " if before.strip().endswith(("?", ":", "return")) else before): + continue + if engine and text[:1].islower() and not text.startswith(("macOS", "iPhone", "iPad")): + continue + if text in keys or text in NAMES or NOT_UI.match(text) or not re.search(r"[A-Za-z]{2}", text): + continue + if not engine and re.search(r"(systemImage|systemName|forKey|environment\[|id:|named:)\s*:?\s*$", before): + continue + missing.append(f"{path}:{code.count(chr(10), 0, start) + 1}: {text!r}") + +for m in missing: + print(m) +print(f"{lang}: {len(keys)} entries, {len(missing)} strings without a translation") +sys.exit(1 if missing else 0)