Repository: canonical/snapcraft

Issue:
Support desktop and icon in appstream handler



Discussion:

Resolving patch:
diff --git a/snapcraft/extractors/_metadata.py b/snapcraft/extractors/_metadata.py
--- a/snapcraft/extractors/_metadata.py
+++ b/snapcraft/extractors/_metadata.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2017 Canonical Ltd
+# Copyright (C) 2017, 2018 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -15,7 +15,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import yaml
-from typing import Any, Dict
+from typing import Any, Dict, List, Union
 
 
 class ExtractedMetadata(yaml.YAMLObject):
@@ -23,19 +23,29 @@ class ExtractedMetadata(yaml.YAMLObject):
 
     yaml_tag = u'!ExtractedMetadata'
 
-    def __init__(self, *, summary='', description='') -> None:
+    def __init__(
+            self, *, summary: str='',
+            description: str='', icon: str='',
+            desktop_file_ids: List[str]=None) -> None:
         """Create a new ExtractedMetadata instance.
 
         :param str summary: Extracted summary
         :param str description: Extracted description
-        """
+        :param str icon: Extracted icon
+        :param dict desktop_file_ids: Extracted desktop file ids, as defined in
+            https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id
+        """  # noqa
 
-        self._data = {}  # type: Dict[str, str]
+        self._data = {}  # type: Dict[str, Union[str, List[str]]]
 
         if summary:
             self._data['summary'] = summary
         if description:
             self._data['description'] = description
+        if icon:
+            self._data['icon'] = icon
+        if desktop_file_ids:
+            self._data['desktop_file_ids'] = desktop_file_ids
 
     def update(self, other: 'ExtractedMetadata') -> None:
         """Update this metadata with other metadata.
@@ -53,7 +63,8 @@ def get_summary(self) -> str:
         :returns: Extracted summary
         :rtype: str
         """
-        return self._data.get('summary')
+        summary = self._data.get('summary')
+        return str(summary) if summary else None
 
     def get_description(self) -> str:
         """Return extracted description.
@@ -61,9 +72,28 @@ def get_description(self) -> str:
         :returns: Extracted description
         :rtype: str
         """
-        return self._data.get('description')
+        description = self._data.get('description')
+        return str(description) if description else None
+
+    def get_icon(self) -> str:
+        """Return extracted icon.
+
+        :returns: Extracted icon
+        :rtype: str
+        """
+        icon = self._data.get('icon')
+        return str(icon) if icon else None
+
+    def get_desktop_file_ids(self) -> List[str]:
+        """Return extracted desktop files ids.
+
+        :returns: Extracted desktop files ids
+        :rtype: list
+        """
+        desktop_file_ids = self._data.get('desktop_file_ids')
+        return list(desktop_file_ids) if desktop_file_ids else None
 
-    def to_dict(self) -> Dict[str, str]:
+    def to_dict(self) -> Dict[str, Union[str, List[str]]]:
         """Return all extracted metadata.
 
         :returns: All extracted metadata in dict form.
diff --git a/snapcraft/extractors/appstream.py b/snapcraft/extractors/appstream.py
--- a/snapcraft/extractors/appstream.py
+++ b/snapcraft/extractors/appstream.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2017 Canonical Ltd
+# Copyright (C) 2017, 2018 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -36,11 +36,30 @@ def extract(path: str) -> ExtractedMetadata:
     summary = None
     node = tree.find('summary')
     if node is not None:
-        summary = node.text
+        summary = node.text.strip()
 
     description = None
     node = tree.find('description')
     if node is not None:
-        description = node.text
+        description = node.text.strip()
 
-    return ExtractedMetadata(summary=summary, description=description)
+    icon = None
+    node = tree.find('icon')
+    if (node is not None and 'type' in node.attrib and
+            node.attrib['type'] == 'local'):
+        # TODO Currently we only support local icons.
+        # See bug https://bugs.launchpad.net/snapcraft/+bug/1742348
+        # for supporting remote icons.
+        # --elopio -20180109
+        icon = node.text.strip()
+
+    desktop_file_ids = []
+    nodes = tree.findall('launchable')
+    for node in nodes:
+        if ('type' in node.attrib and
+                node.attrib['type'] == 'desktop-id'):
+            desktop_file_ids.append(node.text.strip())
+
+    return ExtractedMetadata(
+        summary=summary, description=description, icon=icon,
+        desktop_file_ids=desktop_file_ids)
diff --git a/snapcraft/internal/meta/_snap_packaging.py b/snapcraft/internal/meta/_snap_packaging.py
--- a/snapcraft/internal/meta/_snap_packaging.py
+++ b/snapcraft/internal/meta/_snap_packaging.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2016-2017 Canonical Ltd
+# Copyright (C) 2016-2018 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -17,6 +17,7 @@
 import copy
 import collections
 import contextlib
+import itertools
 import logging
 import os
 import re
@@ -37,6 +38,7 @@
     project_loader,
 )
 from snapcraft import _options
+from snapcraft.extractors import _metadata
 from snapcraft.internal.deprecations import handle_deprecation_notice
 from snapcraft.internal.meta import (
     _desktop,
@@ -119,9 +121,7 @@ def _update_yaml_with_extracted_metadata(
         # precedence over the pull step)
         metadata = part.get_pull_state().extracted_metadata['metadata']
         metadata.update(part.get_build_state().extracted_metadata['metadata'])
-        for key, value in metadata.to_dict().items():
-            if key not in config_data:
-                config_data[key] = value
+        _adopt_info(config_data, metadata)
 
     # Verify that all mandatory keys have been satisfied
     missing_keys = []  # type: List[str]
@@ -133,6 +133,98 @@ def _update_yaml_with_extracted_metadata(
         raise meta_errors.MissingSnapcraftYamlKeysError(keys=missing_keys)
 
 
+def _adopt_info(
+        config_data: Dict[str, Any],
+        extracted_metadata: _metadata.ExtractedMetadata):
+    metadata_dict = extracted_metadata.to_dict()
+    for key, value in metadata_dict.items():
+        # desktop_file_ids are a special case that will be handled
+        # after all the top level snapcraft.yaml keys.
+        if key != 'desktop_file_ids' and key not in config_data:
+            if key == 'icon':
+                if _icon_file_exists() or not os.path.exists(
+                        str(value)):
+                    # Do not overwrite the icon file.
+                    continue
+            config_data[key] = value
+    if 'desktop_file_ids' in metadata_dict:
+        for desktop_file_id in metadata_dict['desktop_file_ids']:
+            app_name = _get_app_name_from_desktop_file_id(
+                config_data, desktop_file_id)
+            if app_name and not _desktop_file_exists(app_name):
+                for xdg_data_dir in ('usr/local/share', 'usr/share'):
+                    desktop_file_path = os.path.join(
+                        xdg_data_dir, 'applications',
+                        desktop_file_id.replace('-', '/'))
+                    if os.path.exists(desktop_file_path):
+                        config_data['apps'][app_name]['desktop'] = (
+                            desktop_file_path)
+
+
+def _icon_file_exists() -> bool:
+    """Check if the icon is specified as a file in the assets dir.
+
+    The icon file can be in two directories: 'setup/gui' (deprecated) or
+    'snap/gui'. The icon can be named 'icon.png' or 'icon.svg'.
+
+    :returns: True if the icon file exists, False otherwise.
+    """
+    for icon_path in (
+            os.path.join(asset_dir, 'gui', icon_file) for
+            (asset_dir, icon_file) in itertools.product(
+                ['setup', 'snap'],
+                ['icon.png', 'icon.svg'])):
+        if os.path.exists(icon_path):
+            return True
+    else:
+        return False
+
+
+def _get_app_name_from_desktop_file_id(
+        config_data: Dict[str, Any], desktop_file_id: str) -> str:
+    """Get the snap app name from a desktop file ID.
+
+    Desktop file IDs are defined in
+    https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id
+
+    XXX We try to find a match between desktop file id and snap app name.
+    This will be much nicer once snapcraft supports appstream IDs:
+    https://forum.snapcraft.io/t/support-for-appstream-id/2327
+    --elopio - 20180108
+
+    :params desktop_file_id: The identifier of the desktop file.
+    :returns: The name of the snap app that corresponds to the desktop file.
+
+    """  # noqa
+    desktop_file_id_parts = desktop_file_id.split('.')
+    if desktop_file_id_parts[-1] == 'desktop':
+        desktop_file_id_parts = desktop_file_id_parts[:-1]
+    app_id = desktop_file_id_parts[-1]
+    if ('apps' in config_data and
+            app_id in config_data['apps'].keys()):
+        return app_id
+    else:
+        return None
+
+
+def _desktop_file_exists(app_name: str) -> bool:
+    """Check if the desktop file is specified as a file in the assets dir.
+
+    The desktop file can be in two directories: 'setup/gui' (deprecated) or
+    'snap/gui'.
+
+    :params app_name: The name of the snap app.
+    :returns: True if the desktop file exists, False otherwise.
+    """
+    for desktop_path in (
+            os.path.join(asset_dir, 'gui', '{}.desktop'.format(app_name)) for
+            asset_dir in ['setup', 'snap']):
+        if os.path.exists(desktop_path):
+            return True
+    else:
+        return False
+
+
 class _SnapPackaging:
 
     @property


Test patch:
diff --git a/snapcraft/tests/data/icon.png b/snapcraft/tests/data/icon.png
new file mode 100644
Binary files /dev/null and b/snapcraft/tests/data/icon.png differ
diff --git a/snapcraft/tests/data/test.desktop b/snapcraft/tests/data/test.desktop
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/data/test.desktop
@@ -0,0 +1,4 @@
+[Desktop Entry]
+Name=Test App
+Exec=test-app
+Type=Application
diff --git a/snapcraft/tests/fixture_setup.py b/snapcraft/tests/fixture_setup.py
--- a/snapcraft/tests/fixture_setup.py
+++ b/snapcraft/tests/fixture_setup.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2015-2017 Canonical Ltd
+# Copyright (C) 2015-2018 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -994,7 +994,8 @@ def __init__(
         self.data = {
             'name': name,
             'version': version,
-            'parts': {}
+            'parts': {},
+            'apps': {}
         }
         if summary is not None:
             self.data['summary'] = summary
@@ -1005,6 +1006,10 @@ def update_part(self, name, data):
         part = {name: data}
         self.data['parts'].update(part)
 
+    def update_app(self, name, data):
+        app = {name: data}
+        self.data['apps'].update(app)
+
     def setUp(self):
         super().setUp()
         with open(os.path.join(self.path, 'snapcraft.yaml'),
diff --git a/snapcraft/tests/integration/general/test_metadata_appstream.py b/snapcraft/tests/integration/general/test_metadata_appstream.py
--- a/snapcraft/tests/integration/general/test_metadata_appstream.py
+++ b/snapcraft/tests/integration/general/test_metadata_appstream.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2017 Canonical Ltd
+# Copyright (C) 2017-2018 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -16,10 +16,12 @@
 
 import os
 import textwrap
+import shutil
 import yaml
 
-from testtools.matchers import Equals
+from testtools.matchers import Equals, FileExists
 
+from snapcraft import tests
 from snapcraft.tests import (
     fixture_setup,
     integration
@@ -37,6 +39,10 @@ def setUp(self):
                 <component>
                   <description>test-appstream-description</description>
                   <summary>test-appstream-summary</summary>
+                  <icon type="local">assets/test-icon.png</icon>
+                  <launchable type="desktop-id">
+                    com.example.test-app.desktop
+                  </launchable>
                 </component>"""))
 
     def test_metadata_extracted_from_appstream(self):
@@ -45,7 +51,7 @@ def test_metadata_extracted_from_appstream(self):
         snapcraft_yaml.data['adopt-info'] = 'test-part'
         snapcraft_yaml.update_part(
             'test-part', {
-                'plugin': 'dump',
+                'plugin': 'nil',
                 'parse-info': ['test.metainfo.xml']})
         self.useFixture(snapcraft_yaml)
 
@@ -76,3 +82,49 @@ def test_specified_metadata_not_overwritten(self):
             snap_yaml['description'], Equals('test-appstream-description'))
         self.assertThat(
             snap_yaml['summary'], Equals('test-summary'))
+
+    def test_icon_extracted_from_appstream(self):
+        os.mkdir('assets')
+        shutil.copyfile(
+            os.path.join(os.path.dirname(tests.__file__), 'data', 'icon.png'),
+            os.path.join(self.path, 'assets', 'test-icon.png'))
+
+        snapcraft_yaml = fixture_setup.SnapcraftYaml(
+            self.path, summary=None, description=None)
+        snapcraft_yaml.data['adopt-info'] = 'test-part'
+        snapcraft_yaml.update_part(
+            'test-part', {
+                'plugin': 'dump',
+                'parse-info': ['test.metainfo.xml']})
+        self.useFixture(snapcraft_yaml)
+
+        self.run_snapcraft('prime')
+
+        self.assertThat(
+            os.path.join('prime', 'meta', 'gui', 'icon.png'), FileExists())
+
+    def test_desktop_extracted_from_appstream(self):
+        xdg_data_dir = os.path.join('usr', 'local', 'share', 'applications')
+        os.makedirs(os.path.join(xdg_data_dir, 'com.example.test'))
+        shutil.copyfile(
+            os.path.join(
+                os.path.dirname(tests.__file__), 'data', 'test.desktop'),
+            os.path.join(
+                self.path, xdg_data_dir, 'com.example.test', 'app.desktop'))
+
+        snapcraft_yaml = fixture_setup.SnapcraftYaml(
+            self.path, summary=None, description=None)
+        snapcraft_yaml.data['adopt-info'] = 'test-part'
+        snapcraft_yaml.update_part(
+            'test-part', {
+                'plugin': 'dump',
+                'parse-info': ['test.metainfo.xml']})
+        snapcraft_yaml.update_app(
+            'test-app', {'command': 'echo'})
+        self.useFixture(snapcraft_yaml)
+
+        self.run_snapcraft('prime')
+
+        self.assertThat(
+            os.path.join('prime', 'meta', 'gui', 'test-app.desktop'),
+            FileExists())
diff --git a/snapcraft/tests/unit/extractors/test_appstream.py b/snapcraft/tests/unit/extractors/test_appstream.py
--- a/snapcraft/tests/unit/extractors/test_appstream.py
+++ b/snapcraft/tests/unit/extractors/test_appstream.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2017 Canonical Ltd
+# Copyright (C) 2017, 2018 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -28,22 +28,42 @@
 class AppstreamTestCase(unit.TestCase):
 
     scenarios = testscenarios.multiply_scenarios(
-        [('summary', {'key': 'summary'}),
-         ('description', {'key': 'description'})],
+        [('summary', {
+            'key': 'summary',
+            'attributes': {},
+            'value': 'test-summary',
+        }),
+         ('description', {
+             'key': 'description',
+             'attributes': {},
+             'value': 'test-description',
+
+         }),
+         ('local icon', {
+            'key': 'icon',
+            'attributes': {'type': 'local'},
+            'value': '/test/path',
+         })],
         [('metainfo', {'file_extension': 'metainfo.xml'}),
          ('appdata', {'file_extension': 'appdata.xml'})]
     )
 
     def test_appstream(self):
         file_name = 'foo.{}'.format(self.file_extension)
+        attributes = ' '.join(
+            '{attribute_name}="{attribute_value}"'.format(
+                attribute_name=attribute,
+                attribute_value=self.attributes[attribute])
+            for attribute in self.attributes)
         with open(file_name, 'w') as f:
             f.write(textwrap.dedent("""\
                 <?xml version="1.0" encoding="UTF-8"?>
                 <component>
-                  <{key}>test-{key}</{key}>
-                </component>""".format(key=self.key)))
+                  <{key} {attributes}>{value}</{key}>
+                </component>""".format(
+                    key=self.key, value=self.value, attributes=attributes)))
 
-        kwargs = {self.key: 'test-{}'.format(self.key)}
+        kwargs = {self.key: self.value}
         expected = ExtractedMetadata(**kwargs)
 
         self.assertThat(
@@ -59,3 +79,46 @@ def test_unhandled_file_test_case(self):
 
         self.assertThat(raised.path, Equals('unhandled-file'))
         self.assertThat(raised.extractor_name, Equals('appstream'))
+
+
+class AppstreamLaunchableTestCase(unit.TestCase):
+
+    def test_appstream_with_launchable(self):
+        with open('foo.metainfo.xml', 'w') as f:
+            f.write(textwrap.dedent("""\
+                <?xml version="1.0" encoding="UTF-8"?>
+                <component>
+                  <launchable type="desktop-id">
+                    com.example.test-app.desktop
+                  </launchable>
+                </component>"""))
+
+        expected = ExtractedMetadata(
+            desktop_file_ids=['com.example.test-app.desktop'])
+
+        self.assertThat(
+            appstream.extract('foo.metainfo.xml'), Equals(expected))
+
+    def test_appstream_with_multiple_launchables(self):
+        with open('foo.metainfo.xml', 'w') as f:
+            f.write(textwrap.dedent("""\
+                <?xml version="1.0" encoding="UTF-8"?>
+                <component>
+                  <launchable type="desktop-id">
+                    com.example.test-app1.desktop
+                  </launchable>
+                  <launchable type="test-wrong-type">
+                    dummy
+                  </launchable>
+                  <launchable type="desktop-id">
+                    com.example.test-app2.desktop
+                  </launchable>
+                </component>"""))
+
+        expected = ExtractedMetadata(
+            desktop_file_ids=[
+                'com.example.test-app1.desktop',
+                'com.example.test-app2.desktop'])
+
+        self.assertThat(
+            appstream.extract('foo.metainfo.xml'), Equals(expected))
diff --git a/snapcraft/tests/unit/pluginhandler/test_pluginhandler.py b/snapcraft/tests/unit/pluginhandler/test_pluginhandler.py
--- a/snapcraft/tests/unit/pluginhandler/test_pluginhandler.py
+++ b/snapcraft/tests/unit/pluginhandler/test_pluginhandler.py
@@ -853,7 +853,10 @@ def test_pull_state_with_extracted_metadata(self, repo_mock):
         def _fake_extractor(file_path):
             return snapcraft.extractors.ExtractedMetadata(
                 summary='test summary',
-                description='test description')
+                description='test description',
+                icon='/test/path',
+                desktop_file_ids=['com.example.test-app.desktop']
+            )
 
         self.useFixture(fixture_setup.FakeMetadataExtractor(
             'fake', _fake_extractor))
@@ -885,6 +888,10 @@ def _fake_extractor(file_path):
         metadata = state.extracted_metadata['metadata']
         self.assertThat(metadata.get_summary(), Equals('test summary'))
         self.assertThat(metadata.get_description(), Equals('test description'))
+        self.assertThat(metadata.get_icon(), Equals('/test/path'))
+        self.assertThat(
+            metadata.get_desktop_file_ids(),
+            Equals(['com.example.test-app.desktop']))
         files = state.extracted_metadata['files']
         self.assertThat(files, Equals(['metadata-file']))
 
@@ -952,7 +959,9 @@ def test_build_state_with_extracted_metadata(self):
         def _fake_extractor(file_path):
             return snapcraft.extractors.ExtractedMetadata(
                 summary='test summary',
-                description='test description')
+                description='test description',
+                icon='/test/path',
+                desktop_file_ids=['com.example.test-app.desktop'])
 
         self.useFixture(fixture_setup.FakeMetadataExtractor(
             'fake', _fake_extractor))
@@ -982,6 +991,10 @@ def _fake_extractor(file_path):
         metadata = state.extracted_metadata['metadata']
         self.assertThat(metadata.get_summary(), Equals('test summary'))
         self.assertThat(metadata.get_description(), Equals('test description'))
+        self.assertThat(metadata.get_icon(), Equals('/test/path'))
+        self.assertThat(
+            metadata.get_desktop_file_ids(),
+            Equals(['com.example.test-app.desktop']))
         files = state.extracted_metadata['files']
         self.assertThat(files, Equals(['metadata-file']))
 
diff --git a/snapcraft/tests/unit/test_meta.py b/snapcraft/tests/unit/test_meta.py
--- a/snapcraft/tests/unit/test_meta.py
+++ b/snapcraft/tests/unit/test_meta.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2015-2017 Canonical Ltd
+# Copyright (C) 2015-2018 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -23,6 +23,7 @@
 import fixtures
 import testtools
 import yaml
+import testscenarios
 from testtools.matchers import (
     Contains,
     Equals,
@@ -454,7 +455,7 @@ def test_create_meta_with_hook(self):
             "Expected generated 'bar' hook to not contain 'plugs'")
 
 
-class CreateMetadataFromSourceErrorsTestCase(CreateBaseTestCase):
+class CreateMetadataFromSourceBaseTestCase(CreateBaseTestCase):
 
     def setUp(self):
         super().setUp()
@@ -469,11 +470,20 @@ def setUp(self):
                     'plugin': 'nil',
                     'parse-info': ['test-metadata-file']
                 }
+            },
+            'apps': {
+                'test-app': {
+                    'command': 'echo'
+                }
             }
         }
         # Create metadata file
         open('test-metadata-file', 'w').close()
 
+
+class CreateMetadataFromSourceErrorsTestCase(
+        CreateMetadataFromSourceBaseTestCase):
+
     def test_create_metadata_with_missing_parse_info(self):
         del self.config_data['summary']
         del self.config_data['parts']['test-part']['parse-info']
@@ -506,6 +516,17 @@ def _fake_extractor(file_path):
         self.assertThat(
             y['description'], Equals(self.config_data['description']))
 
+    def test_metadata_with_unexisting_icon(self):
+        def _fake_extractor(file_path):
+            return extractors.ExtractedMetadata(
+                icon='test/extracted/unexistent/icon/path')
+
+        self.useFixture(fixture_setup.FakeMetadataExtractor(
+            'fake', _fake_extractor))
+
+        # The meta generation should just ignore the dead path, and not fail.
+        self.generate_meta_yaml(build=True)
+
     def test_metadata_satisfies_required_property(self):
         del self.config_data['summary']
 
@@ -544,6 +565,65 @@ def _fake_extractor(file_path):
         self.assertThat(raised.keys, Equals("'summary'"))
 
 
+class MetadataFromSourceWithIconFileTestCase(
+        CreateMetadataFromSourceBaseTestCase):
+
+    scenarios = testscenarios.multiply_scenarios(
+        (('setup/gui', dict(directory=os.path.join('setup', 'gui'))),
+         ('snap/gui', dict(directory=os.path.join('snap', 'gui')))),
+        (('icon.png', dict(file_name='icon.png')),
+         ('icon.svg', dict(file_name='icon.svg')))
+    )
+
+    def test_metadata_doesnt_overwrite_icon_file(self):
+        os.makedirs(self.directory)
+        icon_content = 'setup icon'
+        _create_file(
+            os.path.join(self.directory, self.file_name),
+            content=icon_content)
+
+        def _fake_extractor(file_path):
+            return extractors.ExtractedMetadata(
+                icon='test/extracted/unexistent/icon/path')
+
+        self.useFixture(fixture_setup.FakeMetadataExtractor(
+            'fake', _fake_extractor))
+
+        self.generate_meta_yaml(build=True)
+
+        expected_icon = os.path.join(self.meta_dir, 'gui', self.file_name)
+        self.assertThat(expected_icon, FileContains(icon_content))
+
+
+class MetadataFromSourceWithDesktopFileTestCase(
+        CreateMetadataFromSourceBaseTestCase):
+
+    scenarios = (
+        ('setup/gui', dict(directory=os.path.join('setup', 'gui'))),
+        ('snap/gui', dict(directory=os.path.join('snap', 'gui')))
+    )
+
+    def test_metadata_doesnt_overwrite_desktop_file(self):
+        os.makedirs(self.directory)
+        desktop_content = 'setup desktop'
+        _create_file(
+            os.path.join(self.directory, 'test-app.desktop'),
+            content=desktop_content)
+
+        def _fake_extractor(file_path):
+            return extractors.ExtractedMetadata(
+                desktop_file_ids=['com.example.test-app.desktop'])
+
+        self.useFixture(fixture_setup.FakeMetadataExtractor(
+            'fake', _fake_extractor))
+
+        self.generate_meta_yaml(build=True)
+
+        expected_desktop = os.path.join(
+            self.meta_dir, 'gui', 'test-app.desktop')
+        self.assertThat(expected_desktop, FileContains(desktop_content))
+
+
 class WriteSnapDirectoryTestCase(CreateBaseTestCase):
 
     def test_write_snap_directory(self):