diff --git a/src/efd_unpacker/infrastructure/os_utils.py b/src/efd_unpacker/infrastructure/os_utils.py
index c5cc92b..538c1a1 100644
--- a/src/efd_unpacker/infrastructure/os_utils.py
+++ b/src/efd_unpacker/infrastructure/os_utils.py
@@ -1,8 +1,26 @@
+import codecs
+import logging
import os
import sys
import subprocess
import platform
-from typing import List
+from typing import Dict, List
+
+logger = logging.getLogger(__name__)
+
+CONFIG_TEMPLATES_KEY = 'ConfigurationTemplatesLocation='
+
+# BOM однозначно задаёт кодировку. Кодек utf-16 (в отличие от utf-16le)
+# сам снимает BOM и сам определяет порядок байт.
+_BOM_ENCODINGS = (
+ (codecs.BOM_UTF8, 'utf-8-sig'),
+ (codecs.BOM_UTF16_LE, 'utf-16'),
+ (codecs.BOM_UTF16_BE, 'utf-16'),
+)
+
+# Перебор без BOM: платформа 1С пишет конфиг и в utf-8, и в cp1251,
+# и в utf-16 без сигнатуры.
+_FALLBACK_ENCODINGS = ('utf-8', 'cp1251', 'utf-16-le', 'utf-16-be')
def get_1c_configuration_location_default() -> str:
"""Возвращает путь к каталогу распаковки по умолчанию в зависимости от ОС."""
@@ -41,33 +59,100 @@ def get_1c_configuration_location_from_1cestart() -> List[str]:
home = os.path.expanduser('~')
config_paths.append(os.path.join(home, '.1C', '1cestart', '1cestart.cfg'))
for config_path in config_paths:
- if os.path.isfile(config_path):
- encodings_to_try = ['utf-8-sig', 'utf-16le', 'utf-8', 'cp1251']
- for encoding in encodings_to_try:
- try:
- with open(config_path, 'r', encoding=encoding) as f:
- for line in f:
- line = line.strip()
- if line.startswith('ConfigurationTemplatesLocation='):
- value = line.split('=', 1)[1]
- if value and value not in locations:
- locations.append(value)
- break
- except (UnicodeDecodeError, IOError):
- continue
+ if not os.path.isfile(config_path):
+ continue
+ try:
+ with open(config_path, 'rb') as handle:
+ raw = handle.read()
+ except OSError as exc:
+ logger.warning("Не удалось прочитать %s: %s", config_path, exc)
+ continue
+ for value in _locations_from_text(_decode_config(raw)):
+ if value not in locations:
+ locations.append(value)
return locations
+
+def _decode_config(raw: bytes) -> str:
+ """
+ Декодирует 1cestart.cfg, выбирая кодировку по результату, а не по отсутствию ошибки.
+
+ Прежний перебор останавливался на первом кодеке, который не бросил исключение,
+ а utf-16le не бросает почти никогда: любая последовательность чётной длины
+ декодируется в иероглифы. Поэтому корректно разбирался только utf-8, а cp1251
+ проходил через раз — в зависимости от чётности размера файла. Критерий теперь
+ прямой: в тексте должен найтись сам ключ.
+ """
+ encodings = []
+ for bom, encoding in _BOM_ENCODINGS:
+ if raw.startswith(bom):
+ encodings.append(encoding)
+ break
+ encodings.extend(_FALLBACK_ENCODINGS)
+
+ fallback = ""
+ for encoding in encodings:
+ try:
+ text = raw.decode(encoding)
+ except UnicodeError:
+ continue
+ if CONFIG_TEMPLATES_KEY in text:
+ return text
+ if not fallback:
+ fallback = text
+ return fallback
+
+
+def _locations_from_text(text: str) -> List[str]:
+ """Значения ConfigurationTemplatesLocation из уже декодированного текста."""
+ found = []
+ for line in text.splitlines():
+ # lstrip до strip: \ufeff не пробельный, и utf-16le оставлял BOM в начале
+ # первой строки, из-за чего она не проходила проверку префикса.
+ line = line.lstrip('\ufeff').strip()
+ if line.startswith(CONFIG_TEMPLATES_KEY):
+ value = line.split('=', 1)[1]
+ if value:
+ found.append(value)
+ return found
+
+def child_environment() -> Dict[str, str]:
+ """
+ Окружение для системной команды открытия папки.
+
+ Бутлоадер onefile-сборки PyInstaller указывает LD_LIBRARY_PATH на каталог
+ распаковки, а оригинал кладёт в LD_LIBRARY_PATH_ORIG. Без восстановления
+ дочерний gio/kioclient/gtk-launch подхватывает оттуда Qt и libstdc++ вместо
+ системных и падает с symbol lookup error.
+ """
+ env = os.environ.copy()
+ original = env.pop("LD_LIBRARY_PATH_ORIG", None)
+ if original is None:
+ env.pop("LD_LIBRARY_PATH", None)
+ else:
+ env["LD_LIBRARY_PATH"] = original
+ return env
+
+
def open_folder(path: str) -> bool:
"""Открывает указанный путь в системном файловом менеджере."""
if not os.path.exists(path):
return False
+ # abspath, а не разделитель опций "--": xdg-open его не понимает и отвечает
+ # `unexpected option` с кодом 1. Абсолютный путь не может начинаться с дефиса.
+ target = os.path.abspath(path)
try:
if platform.system() == "Windows":
- os.startfile(path) # type: ignore[attr-defined]
- elif platform.system() == "Darwin": # macOS
- subprocess.run(["open", path])
- else: # Linux
- subprocess.run(["xdg-open", path])
+ os.startfile(target) # type: ignore[attr-defined]
+ return True
+ command = "open" if platform.system() == "Darwin" else "xdg-open"
+ # Код возврата раньше не читался: xdg-open отвечает 3 или 4, когда
+ # ассоциации inode/directory нет, а пользователь видел «открыл».
+ result = subprocess.run([command, target], env=child_environment())
+ if result.returncode != 0:
+ logger.warning("%s вернул код %s для %s", command, result.returncode, target)
+ return False
return True
- except Exception:
+ except Exception as exc:
+ logger.warning("Не удалось открыть папку %s: %s", target, exc)
return False
diff --git a/src/efd_unpacker/infrastructure/settings_service.py b/src/efd_unpacker/infrastructure/settings_service.py
index 84888ab..4bec8d0 100644
--- a/src/efd_unpacker/infrastructure/settings_service.py
+++ b/src/efd_unpacker/infrastructure/settings_service.py
@@ -18,7 +18,16 @@ def __init__(self, translator: Translator, settings: Optional[QSettings] = None)
self.settings = settings or QSettings("efd_unpacker", "settings")
def get_output_path(self) -> str:
- return self.settings.value("output_path", get_1c_configuration_location_default())
+ default = get_1c_configuration_location_default()
+ value = self.settings.value("output_path", default)
+ # QSettings отдаёт то, что лежит в файле: конфиг, правленный извне,
+ # миграция или REG_MULTI_SZ дают list, а os.path.normpath дальше роняет
+ # запуск ещё до window.show() — без окна и без сообщения.
+ # ','.join тут нельзя: Qt при разборе срезает пробел после запятой,
+ # и склейка даст молча неверный каталог вместо честного отката.
+ if not isinstance(value, str):
+ return default
+ return value
def set_output_path(self, path: str) -> None:
self.settings.setValue("output_path", path)
diff --git a/src/efd_unpacker/presentation/ui.py b/src/efd_unpacker/presentation/ui.py
index 0509b3b..8427099 100644
--- a/src/efd_unpacker/presentation/ui.py
+++ b/src/efd_unpacker/presentation/ui.py
@@ -389,5 +389,16 @@ def closeEvent(self, event) -> None:
event.accept()
def open_output_folder(self) -> None:
- if self.output_path:
- open_folder(self.output_path)
+ if not self.output_path:
+ return
+ if open_folder(self.output_path):
+ return
+ # QMessageBox, а не show_message: последний переводит окно в UIState.ERROR
+ # и прячет саму кнопку «Открыть папку». Путь в тексте — чтобы его можно
+ # было скопировать: в состоянии SUCCESS комбобокс с путём скрыт, и узнать
+ # каталог распаковки из окна больше неоткуда.
+ QMessageBox.warning(
+ self,
+ self._t("MainWindow", "Error"),
+ "{}\n\n{}".format(self._t("MainWindow", "Could not open the folder"), self.output_path),
+ )
diff --git a/tests/qt/test_main_window.py b/tests/qt/test_main_window.py
index c72966a..452c1ab 100644
--- a/tests/qt/test_main_window.py
+++ b/tests/qt/test_main_window.py
@@ -13,6 +13,7 @@
from efd_unpacker.domain.file_validator import FileValidator
from efd_unpacker.domain.unpack_service import UnpackService
+from efd_unpacker.presentation import ui
from efd_unpacker.presentation.ui import MainWindow, UnpackThread
@@ -215,3 +216,56 @@ def test_window_shows_the_message_without_cli_markers(qtbot, success):
assert shown == "Распаковка завершена успешно"
assert "[OK]" not in shown
assert "[ERROR]" not in shown
+
+
+def test_failed_folder_open_is_reported_to_the_user(qtbot, monkeypatch, tmp_path):
+ """
+ Регресс #18: open_folder возвращал False, а вызывающий код результат не читал.
+ В состоянии SUCCESS комбобокс с путём скрыт, так что при молчаливом отказе
+ каталог распаковки узнать из окна было негде.
+ """
+ window = _plain_window(qtbot)
+ window.output_path = str(tmp_path)
+ shown = {}
+
+ monkeypatch.setattr(ui.MainWindow, "_t", lambda _self, _ctx, text: text)
+ monkeypatch.setattr(ui, "open_folder", lambda _path: False)
+ monkeypatch.setattr(
+ ui.QMessageBox, "warning", lambda _parent, title, text: shown.update(title=title, text=text)
+ )
+
+ window.open_output_folder()
+
+ assert "Could not open the folder" in shown["text"]
+ assert str(tmp_path) in shown["text"], "путь должен быть в сообщении, чтобы его можно было скопировать"
+
+
+def test_successful_folder_open_is_silent(qtbot, monkeypatch, tmp_path):
+ window = _plain_window(qtbot)
+ window.output_path = str(tmp_path)
+ calls = []
+
+ monkeypatch.setattr(ui, "open_folder", lambda _path: True)
+ monkeypatch.setattr(ui.QMessageBox, "warning", lambda *a, **k: calls.append(a))
+
+ window.open_output_folder()
+
+ assert calls == []
+
+
+def test_failed_folder_open_keeps_the_window_in_success_state(qtbot, monkeypatch, tmp_path):
+ """
+ show_message(..., is_error=True) перевёл бы окно в UIState.ERROR и спрятал
+ саму кнопку «Открыть папку» — поэтому здесь QMessageBox, а не он.
+ """
+ window = _plain_window(qtbot)
+ window.unpack_finished(True, "Готово")
+ window.output_path = str(tmp_path)
+
+ monkeypatch.setattr(ui, "open_folder", lambda _path: False)
+ monkeypatch.setattr(ui.QMessageBox, "warning", lambda *a, **k: None)
+
+ window.open_output_folder()
+
+ assert window.btn_open_folder.isVisible() or not window.isVisible()
+ assert window.btn_retry.isVisible() is False
diff --git a/tests/unit/test_os_utils.py b/tests/unit/test_os_utils.py
index c9dd4d7..4725fee 100644
--- a/tests/unit/test_os_utils.py
+++ b/tests/unit/test_os_utils.py
@@ -99,22 +99,36 @@ def test_cfg_cp1251_with_odd_length_works(tmp_path, monkeypatch):
assert os_utils.get_1c_configuration_location_from_1cestart() == ["/opt/Шаблоны"]
-@pytest.mark.xfail(
- strict=True,
- reason="#18: платформа 1С пишет конфиг в UTF-16, сейчас такой файл даёт пустой список",
+@pytest.mark.parametrize(
+ "encoding",
+ ["utf-16", "utf-16-le", "utf-16-be", "utf-16-le-bom", "utf-16-be-bom"],
+ ids=["utf-16+BOM", "utf-16le без BOM", "utf-16be без BOM", "utf-16le с BOM", "utf-16be с BOM"],
)
-@pytest.mark.parametrize("encoding", ["utf-16", "utf-16-le"], ids=["utf-16+BOM", "utf-16le без BOM"])
-def test_cfg_utf16_is_not_parsed(tmp_path, monkeypatch, encoding):
- _write_config(tmp_path, monkeypatch, (CONFIG_LINE + "\r\n").encode(encoding))
+def test_cfg_utf16_in_every_shape(tmp_path, monkeypatch, encoding):
+ """
+ Регресс #18: utf-16le не снимает BOM, а \ufeff не пробельный, поэтому первая
+ строка не проходила проверку префикса. Без BOM файл вообще не доходил до
+ своей ветки: байты UTF-16LE для ASCII валидны как UTF-8, и utf-8-sig
+ отрабатывал первым, ничего не находил и обрывал перебор.
+ """
+ if encoding.endswith("-bom"):
+ base = encoding[: -len("-bom")]
+ payload = (b"\xff\xfe" if base.endswith("le") else b"\xfe\xff") + (
+ CONFIG_LINE + "\r\n"
+ ).encode(base)
+ else:
+ payload = (CONFIG_LINE + "\r\n").encode(encoding)
+ _write_config(tmp_path, monkeypatch, payload)
assert os_utils.get_1c_configuration_location_from_1cestart() == ["/opt/1c/tmplts"]
-@pytest.mark.xfail(
- strict=True,
- reason="#18: utf-16le декодирует любую чётную последовательность без исключения и обрывает перебор",
-)
-def test_cfg_cp1251_with_even_length_is_lost(tmp_path, monkeypatch):
+def test_cfg_cp1251_with_even_length_is_parsed(tmp_path, monkeypatch):
+ """
+ Регресс #18: чётная длина уводила файл в utf-16le, который декодирует любую
+ последовательность без исключения. Разбор cp1251 работал через раз —
+ в зависимости от чётности размера файла.
+ """
payload = "ConfigurationTemplatesLocation=/opt/Шаблоныx\r\n".encode("cp1251")
assert len(payload) % 2 == 0
_write_config(tmp_path, monkeypatch, payload)
@@ -122,6 +136,45 @@ def test_cfg_cp1251_with_even_length_is_lost(tmp_path, monkeypatch):
assert os_utils.get_1c_configuration_location_from_1cestart() == ["/opt/Шаблоныx"]
+def test_cfg_large_cp1251_file_is_not_truncated(tmp_path, monkeypatch):
+ """
+ Регресс #18: locations накапливался между попытками, поэтому частично
+ прочитанное под utf-8-sig оставалось, utf-16le доедал остаток и делал break.
+ На файле из 251 строки возвращалось 174.
+ """
+ lines = []
+ for index in range(251):
+ suffix = "Шаблоны" if index == 230 else str(index)
+ lines.append("ConfigurationTemplatesLocation=/opt/%s" % suffix)
+ payload = ("\r\n".join(lines) + "\r\n").encode("cp1251")
+ assert len(payload) > 8192
+ _write_config(tmp_path, monkeypatch, payload)
+
+ result = os_utils.get_1c_configuration_location_from_1cestart()
+
+ assert len(result) == 251
+ assert "/opt/Шаблоны" in result
+
+
+def test_cfg_without_the_key_returns_empty(tmp_path, monkeypatch):
+ """Файл без ключа — не повод перебирать кодировки до иероглифов."""
+ _write_config(tmp_path, monkeypatch, "CommonInfoBases=x\r\n".encode("cp1251"))
+
+ assert os_utils.get_1c_configuration_location_from_1cestart() == []
+
+
+def test_cfg_unreadable_file_does_not_raise(tmp_path, monkeypatch):
+ home = _write_config(tmp_path, monkeypatch, (CONFIG_LINE + "\n").encode("utf-8"))
+ config = home / ".1C" / "1cestart" / "1cestart.cfg"
+ os.chmod(config, 0o000)
+ try:
+ if os.access(config, os.R_OK):
+ pytest.skip("права не отзываются на этой платформе")
+ assert os_utils.get_1c_configuration_location_from_1cestart() == []
+ finally:
+ os.chmod(config, 0o644)
+
+
def test_cfg_windows_reads_appdata_and_allusersprofile(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "platform", "win32")
for name, value in (("APPDATA", tmp_path / "roaming"), ("ALLUSERSPROFILE", tmp_path / "all")):
@@ -156,6 +209,17 @@ def test_open_folder_windows_uses_startfile(monkeypatch, tmp_path):
startfile.assert_called_once_with(str(target))
+def _completed(returncode=0):
+ """subprocess.run-заглушка, фиксирующая аргументы и отдающая заданный код."""
+ calls = []
+
+ def fake_run(args, **kwargs):
+ calls.append((args, kwargs))
+ return subprocess.CompletedProcess(args, returncode)
+
+ return fake_run, calls
+
+
@pytest.mark.parametrize(
"system, expected_command",
[("Darwin", "open"), ("Linux", "xdg-open")],
@@ -163,13 +227,85 @@ def test_open_folder_windows_uses_startfile(monkeypatch, tmp_path):
def test_open_folder_uses_platform_command(monkeypatch, tmp_path, system, expected_command):
target = tmp_path / "tmplts"
target.mkdir()
- run = mock.Mock()
+ fake_run, calls = _completed()
monkeypatch.setattr(os_utils.platform, "system", lambda: system)
- monkeypatch.setattr(os_utils.subprocess, "run", run)
+ monkeypatch.setattr(os_utils.subprocess, "run", fake_run)
+
+ assert os_utils.open_folder(str(target)) is True
+ assert calls[0][0] == [expected_command, str(target)]
+
+
+@pytest.mark.parametrize("returncode", [1, 3, 4], ids=["rc=1", "rc=3", "rc=4"])
+def test_open_folder_reports_a_nonzero_exit_code(monkeypatch, tmp_path, returncode):
+ """
+ Регресс #18: код возврата не читался вообще. xdg-open отвечает 3 или 4,
+ когда ассоциации inode/directory нет, а пользователь видел «открыл».
+ """
+ target = tmp_path / "tmplts"
+ target.mkdir()
+ fake_run, _calls = _completed(returncode)
+
+ monkeypatch.setattr(os_utils.platform, "system", lambda: "Linux")
+ monkeypatch.setattr(os_utils.subprocess, "run", fake_run)
+
+ assert os_utils.open_folder(str(target)) is False
+
+
+def test_open_folder_passes_a_relative_path_as_absolute(monkeypatch, tmp_path):
+ """
+ Путь, начинающийся с дефиса, не должен уехать в команду как опция.
+ Разделитель "--" тут не годится: xdg-open его не понимает.
+ """
+ target = tmp_path / "-tmplts"
+ target.mkdir()
+ fake_run, calls = _completed()
+
+ monkeypatch.setattr(os_utils.platform, "system", lambda: "Linux")
+ monkeypatch.setattr(os_utils.subprocess, "run", fake_run)
assert os_utils.open_folder(str(target)) is True
- run.assert_called_once_with([expected_command, str(target)])
+ # Именно isabs, а не startswith(os.sep): на Windows абсолютный путь
+ # начинается с буквы диска, и проверка по разделителю там всегда ложна.
+ assert os.path.isabs(calls[0][0][1])
+ assert "--" not in calls[0][0]
+
+
+def test_open_folder_restores_the_library_path_for_the_child(monkeypatch, tmp_path):
+ """
+ Регресс #18: дочерний процесс наследовал LD_LIBRARY_PATH бутлоадера
+ PyInstaller и подхватывал Qt из каталога распаковки вместо системного.
+ """
+ target = tmp_path / "tmplts"
+ target.mkdir()
+ fake_run, calls = _completed()
+
+ monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEIxxxxxx")
+ monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/usr/lib/x86_64-linux-gnu")
+ monkeypatch.setattr(os_utils.platform, "system", lambda: "Linux")
+ monkeypatch.setattr(os_utils.subprocess, "run", fake_run)
+
+ os_utils.open_folder(str(target))
+
+ env = calls[0][1]["env"]
+ assert env["LD_LIBRARY_PATH"] == "/usr/lib/x86_64-linux-gnu"
+ assert "LD_LIBRARY_PATH_ORIG" not in env
+
+
+def test_child_environment_drops_the_library_path_without_an_original(monkeypatch):
+ """Если ORIG нет, ключ надо убрать, а не оставить путь бутлоадера."""
+ monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEIxxxxxx")
+ monkeypatch.delenv("LD_LIBRARY_PATH_ORIG", raising=False)
+
+ env = os_utils.child_environment()
+
+ assert "LD_LIBRARY_PATH" not in env
+
+
+def test_child_environment_keeps_the_rest_of_the_environment(monkeypatch):
+ monkeypatch.setenv("SOME_MARKER", "значение")
+
+ assert os_utils.child_environment()["SOME_MARKER"] == "значение"
def test_open_folder_swallows_launcher_failure(monkeypatch, tmp_path):
@@ -186,18 +322,3 @@ def boom(*_args, **_kwargs):
assert os_utils.open_folder(str(target)) is False
-def test_open_folder_real_subprocess_signature(monkeypatch, tmp_path):
- """Проверяем, что вызов действительно совместим с subprocess.run."""
- target = tmp_path / "tmplts"
- target.mkdir()
- calls = []
-
- def fake_run(args, **kwargs):
- calls.append(args)
- return subprocess.CompletedProcess(args, 0)
-
- monkeypatch.setattr(os_utils.platform, "system", lambda: "Darwin")
- monkeypatch.setattr(os_utils.subprocess, "run", fake_run)
-
- assert os_utils.open_folder(str(target)) is True
- assert calls == [["open", str(target)]]
diff --git a/tests/unit/test_settings_service.py b/tests/unit/test_settings_service.py
index b7c3aa5..bfb0024 100644
--- a/tests/unit/test_settings_service.py
+++ b/tests/unit/test_settings_service.py
@@ -1,6 +1,9 @@
import unittest
from unittest.mock import MagicMock, patch
+from PyQt5.QtCore import QSettings
+
+from efd_unpacker.infrastructure import settings_service as settings_service_module
from efd_unpacker.infrastructure.settings_service import SettingsService
@@ -56,3 +59,47 @@ def test_get_output_path_items(self, mock_settings, mock_from_1c, mock_default)
if __name__ == "__main__":
unittest.main()
+
+
+def test_non_string_setting_falls_back_to_default(tmp_path, monkeypatch):
+ """
+ Регресс #18: QSettings отдаёт то, что лежит в файле. Конфиг, правленный
+ извне, даёт list, а os.path.normpath дальше роняет запуск ещё до
+ window.show() — без окна и без сообщения.
+ """
+ ini = tmp_path / "settings.ini"
+ ini.write_text("[General]\noutput_path=/home/u/Templates, old/tmplts\n", encoding="utf-8")
+ settings = QSettings(str(ini), QSettings.IniFormat)
+ assert not isinstance(settings.value("output_path"), str), "иначе тест проверяет не то"
+
+ monkeypatch.setattr(
+ settings_service_module, "get_1c_configuration_location_default", lambda: "/default/tmplts"
+ )
+ monkeypatch.setattr(
+ settings_service_module, "get_1c_configuration_location_from_1cestart", lambda: []
+ )
+ service = SettingsService(translator=_DummyTranslator(), settings=settings)
+
+ assert service.get_output_path() == "/default/tmplts"
+ # Метка именно (last used): откат отдаёт тот же путь, что и default, ветка
+ # last_used срабатывает первой и занимает его. Это существующее поведение
+ # разметки, а не следствие отката — трогать его здесь незачем.
+ assert service.get_output_path_items() == [("/default/tmplts", "/default/tmplts (last used)")]
+
+
+def test_string_setting_is_used_as_is(tmp_path, monkeypatch):
+ ini = tmp_path / "settings.ini"
+ ini.write_text("[General]\noutput_path=/home/u/Templates\n", encoding="utf-8")
+ settings = QSettings(str(ini), QSettings.IniFormat)
+
+ monkeypatch.setattr(
+ settings_service_module, "get_1c_configuration_location_default", lambda: "/default/tmplts"
+ )
+ service = SettingsService(translator=_DummyTranslator(), settings=settings)
+
+ assert service.get_output_path() == "/home/u/Templates"
+
+
+class _DummyTranslator:
+ def translate(self, _context, source):
+ return source
diff --git a/translations/ru.ts b/translations/ru.ts
index d33751a..8d190b3 100644
--- a/translations/ru.ts
+++ b/translations/ru.ts
@@ -88,6 +88,10 @@
No .efd file selected
Не выбран файл .efd
+
+ Could not open the folder
+ Не удалось открыть папку
+
UnpackService