Fermer

février 20, 2023

Applications de bureau rapides et multiplateformes —

Applications de bureau rapides et multiplateformes —


Dans ce didacticiel, nous allons explorer Épreuvesun cadre multiplateforme moderne pour la création d’applications de bureau.

Contenu:

  1. Qu’est-ce que Tauri ?
  2. Tauri vs Electron : une comparaison rapide
  3. Créer une application de prise de notes
  4. Conclusion

Pendant de nombreuses années, Électron était le cadre multiplateforme de facto pour la création d’applications de bureau. Visual Studio Code, MongoDB Compass et Postman sont tous d’excellents exemples d’applications créées avec ce framework. Electron est vraiment génial, mais il présente des inconvénients importants, que d’autres frameworks modernes ont surmontés – Tauri étant l’un des meilleurs d’entre eux.

Qu’est-ce que Tauri ?

Tauri est un cadre moderne qui vous permet de concevoir, développer et créer des applications multiplateformes à l’aide de technologies Web familières telles que HTML, CSS et JavaScript sur le frontend, tout en tirant parti du puissant Langage de programmation Rust sur le backend.

Tauri est indépendant du framework. Cela signifie que vous pouvez l’utiliser avec n’importe quelle bibliothèque frontale de votre choix, telle que Vue, React, Svelte, etc. De plus, l’utilisation de Rust dans un projet basé sur Tauri est totalement facultative. Vous pouvez utiliser uniquement l’API JavaScript fournie par Tauri pour créer l’intégralité de votre application. Cela permet non seulement de créer une nouvelle application, mais également de prendre la base de code d’une application Web que vous avez déjà créée et de la transformer en une application de bureau native tout en ayant à peine besoin de modifier le code d’origine.

Regardons pourquoi nous devrions utiliser Tauri au lieu d’Electron.

Tauri vs Electron : une comparaison rapide

Il y a trois éléments importants pour créer une très bonne application. L’application doit être petite, rapide et sécurisée. Tauri surpasse Electron dans les trois :

  • Tauri produit des binaires beaucoup plus petits. Comme vous pouvez le voir sur benchmarks résultats publiés par Taurimême un super simple Bonjour le monde! L’application peut avoir une taille énorme (plus de 120 Mo) lorsqu’elle est construite avec Electron. En revanche, la taille binaire de la même application Tauri est beaucoup plus petite, inférieure à 2 Mo. C’est assez impressionnant à mon avis.
  • Les applications Tauri fonctionnent beaucoup plus rapidement. À partir de la même page mentionnée ci-dessus, vous pouvez également voir que l’utilisation de la mémoire des applications Tauri pourrait être près de la moitié de celle d’une application Electron équivalente.
  • Les applications Tauri sont hautement sécurisées. Sur le site Web de Tauri, vous pouvez lire sur toutes les fonctions de sécurité intégrées Tauri fournit par défaut. Mais une caractéristique notable que je veux mentionner ici est que les développeurs peuvent explicitement activer ou désactiver certaines API. Cela permet non seulement de sécuriser votre application, mais également de réduire la taille binaire.

Créer une application de prise de notes

Dans cette section, nous allons créer une application de prise de notes simple avec les fonctionnalités suivantes :

  • ajouter et supprimer des notes
  • renommer le titre d’une note
  • modifier le contenu d’une note dans Markdown
  • prévisualiser le contenu d’une note en HTML
  • enregistrer des notes dans le stockage local
  • importer et exporter des notes sur le disque dur du système

Vous pouvez trouver tous les fichiers de projet sur GitHub.

Commencer

Pour démarrer avec Tauri, vous devez d’abord installer Rust et ses dépendances système. Ils sont différents selon le système d’exploitation de l’utilisateur, je ne vais donc pas les explorer ici. Veuillez suivre les instructions de votre système d’exploitation dans la documentation.

Lorsque vous êtes prêt, dans un répertoire de votre choix, exécutez la commande suivante :

Cela vous guidera tout au long du processus d’installation comme indiqué ci-dessous :

$ npm create tauri-app

We hope to help you create something special with Tauri!
You will have a choice of one of the UI frameworks supported by the greater web tech community.
This tool should get you quickly started. See our docs at https://tauri.app/

If you haven't already, please take a moment to setup your system.
You may find the requirements here: https://tauri.app/v1/guides/getting-started/prerequisites  
    
Press any key to continue...
? What is your app name? my-notes
? What should the window title be? My Notes
? What UI recipe would you like to add? create-vite (vanilla, vue, react, svelte, preact, lit) (https://vitejs.dev/guide/
? Add "@tauri-apps/api" npm package? Yes
? Which vite template would you like to use? react-ts
>> Running initial command(s)
Need to install the following packages:
  create-vite@3.2.1
Ok to proceed? (y) y

>> Installing any additional needed dependencies

added 87 packages, and audited 88 packages in 19s

9 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

added 2 packages, and audited 90 packages in 7s

10 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
>> Updating "package.json"
>> Running "tauri init"

> my-notes@0.0.0 tauri
> tauri init --app-name my-notes --window-title My Notes --dist-dir ../dist --dev-path http://localhost:5173

✔ What is your frontend dev command? · npm run dev
✔ What is your frontend build command? · npm run build
>> Updating "tauri.conf.json"
>> Running final command(s)

    Your installation completed.

    $ cd my-notes
    $ npm run tauri dev

Veuillez vous assurer que vos choix correspondent à ceux que j’ai faits, qui sont principalement l’échafaudage d’une application React avec le support Vite et TypeScript et l’installation du package API Tauri.

Ne lancez pas encore l’application. Nous devons d’abord installer quelques packages supplémentaires nécessaires à notre projet. Exécutez les commandes suivantes dans votre terminal :

npm install @mantine/core @mantine/hooks @tabler/icons @emotion/react marked-react

Cela installera les packages suivants :

Nous sommes maintenant prêts à tester l’application, mais avant cela, voyons comment le projet est structuré :

my-notes/
├─ node_modules/
├─ public/
├─ src/
│   ├─ assets/
│   │   └─ react.svg
│   ├─ App.css
│   ├─ App.tsx
│   ├─ index.css
│   ├─ main.tsx
│   └─ vite-env.d.ts
├─ src-tauri/
│   ├─ icons/
│   ├─ src/
│   ├─ .gitignore
│   ├─ build.rs
│   ├─ Cargo.toml
│   └─ tauri.config.json
├─ .gitignore
├─ index.html
├─ package-lock.json
├─ package.json
├─ tsconfig.json
├─ tsconfig.node.json
└─ vite.config.ts

La chose la plus importante ici est que la partie React de l’application est stockée dans le src répertoire et Rust et d’autres fichiers spécifiques à Tauri sont stockés dans src-tauri. Le seul fichier que nous devons toucher dans le répertoire Tauri est tauri.conf.json, où nous pouvons configurer l’application. Ouvrez ce fichier et trouvez le allowlist clé. Remplacez son contenu par ce qui suit :

"allowlist": {
  "dialog": {
    "save": true,
    "open": true,
    "ask": true
  },
  "fs": {
    "writeFile": true,
    "readFile": true,
    "scope": ["$DOCUMENT/*", "$DESKTOP/*"]
  },
  "path": {
    "all": true
  },
  "notification": {
    "all": true
  }
},

Ici, pour des raisons de sécurité, comme je l’ai mentionné ci-dessus, nous n’activons que les API que nous allons utiliser dans notre application. Nous restreignons également l’accès au système de fichiers avec seulement deux exceptions — le Documents et Desktop répertoires. Cela permettra aux utilisateurs d’exporter leurs notes uniquement vers ces répertoires.

Nous devons changer encore une chose avant de fermer le fichier. Trouvez le bundle clé. Sous cette clé, vous trouverez le identifier clé. Modifiez sa valeur en com.mynotes.dev. Ceci est nécessaire lors de la création de l’application, car l’identifiant doit être unique.

La dernière chose que je veux mentionner est que, dans la dernière windows clé, vous pouvez configurer tous les paramètres liés à la fenêtre :

"windows": [
  {
    "fullscreen": false,
    "height": 600,
    "resizable": true,
    "title": "My Notes",
    "width": 800
  }
]

Comme vous pouvez le voir, le title clé a été configurée pour vous en fonction de la valeur que vous lui avez donnée lors de l’installation.

OK, alors lançons enfin l’application. Dans le my-notes répertoire, exécutez la commande suivante :

Vous devrez attendre un moment jusqu’à ce que l’installation de Tauri soit terminée et que tous les fichiers aient été compilés pour la première fois. Ne t’inquiète pas. Dans les versions suivantes, le processus sera beaucoup plus rapide. Lorsque Tauri est prêt, il ouvre automatiquement la fenêtre de l’application. L’image ci-dessous montre ce que vous devriez voir.

Notre nouvelle fenêtre Tauri

Remarque : après l’exécution de l’application en mode développement ou sa génération, un nouveau target répertoire est créé à l’intérieur src-tauri, qui contient tous les fichiers compilés. En mode dev, ils sont placés dans le debug sous-répertoire, et en mode construction, ils sont placés dans le release sous-répertoire.

OK, adaptons maintenant les fichiers à nos besoins. Tout d’abord, supprimez le index.css et App.css des dossiers. Ouvrez ensuite le main.tsx fichier et remplacez son contenu par ce qui suit :

import React from 'react'
import ReactDOM from 'react-dom/client'
import { MantineProvider } from '@mantine/core'
import App from './App'

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <MantineProvider withGlobalStyles withNormalizeCSS>
      <App />
    </MantineProvider>
  </React.StrictMode>
)

Cela configure les composants de Mantine à utiliser.

Ensuite, ouvrez le App.tsx fichier et remplacez son contenu par ce qui suit :

import { useState } from 'react'
import { Button } from '@mantine/core'

function App() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Button onClick={() => setCount((count) => count + 1)}>count is {count}</Button>
    </div>
  )
}

export default App

Maintenant, si vous regardez dans la fenêtre de l’application, vous devriez voir ce qui suit :

La nouvelle fenêtre d'application vide

Assurez-vous que l’application fonctionne correctement en cliquant sur le bouton. Si quelque chose ne va pas, vous devrez peut-être le déboguer. (Voir la remarque suivante.)

Remarque : lorsque l’application s’exécute en mode développement, vous pouvez ouvrir les DevTools en cliquant avec le bouton droit sur la fenêtre de l’application et en sélectionnant Inspecter du menu.

Création de la fonctionnalité de l’application de base

Créons maintenant le squelette de notre application. Remplacer le contenu du App.tsx fichier avec les éléments suivants :

import { useState } from 'react'
import Markdown from 'marked-react'

import { ThemeIcon, Button, CloseButton, Switch, NavLink, Flex, Grid, Divider, Paper, Text, TextInput, Textarea } from '@mantine/core'
import { useLocalStorage } from '@mantine/hooks'
import { IconNotebook, IconFilePlus, IconFileArrowLeft, IconFileArrowRight } from '@tabler/icons'

import { save, open, ask } from '@tauri-apps/api/dialog'
import { writeTextFile, readTextFile } from '@tauri-apps/api/fs'
import { sendNotification } from '@tauri-apps/api/notification'

function App() {
  const [notes, setNotes] = useLocalStorage({ key: "my-notes", defaultValue: [  {
    "title": "New note",
    "content": ""
  }] })

  const [active, setActive] = useState(0)
  const [title, setTitle] = useState("")
  const [content, setContent] = useState("")
  const [checked, setChecked] = useState(false)

  const handleSelection = (title: string, content: string, index: number) => {
    setTitle(title)
    setContent(content)
    setActive(index)
  }

  const addNote = () => {
    notes.splice(0, 0, {title: "New note", content: ""})
    handleSelection("New note", "", 0)
    setNotes([...notes])
  }

  const deleteNote = async (index: number) => {
    let deleteNote = await ask("Are you sure you want to delete this note?", {
      title: "My Notes",
      type: "warning",
    })
    if (deleteNote) {
      notes.splice(index,1)
      if (active >= index) {
        setActive(active >= 1 ? active - 1 : 0)
      }
      if (notes.length >= 1) {
        setContent(notes[index-1].content)
      } else {
        setTitle("")
        setContent("")
      } 
      setNotes([...notes]) 
    }
  }

  return (
    <div>
      <Grid grow m={10}>
        <Grid.Col span="auto">
          <Flex gap="xl" justify="flex-start" align="center" wrap="wrap">
            <Flex>
              <ThemeIcon size="lg" variant="gradient" gradient={{ from: "teal", to: "lime", deg: 90 }}>
                <IconNotebook size={32} />
              </ThemeIcon>
              <Text color="green" fz="xl" fw={500} ml={5}>My Notes</Text>
            </Flex>
            <Button onClick={addNote} leftIcon={<IconFilePlus />}>Add note</Button>
            <Button.Group>
              <Button variant="light" leftIcon={<IconFileArrowLeft />}>Import</Button>
              <Button variant="light" leftIcon={<IconFileArrowRight />}>Export</Button>
            </Button.Group>
          </Flex>

          <Divider my="sm" />

          {notes.map((note, index) => (
            <Flex key={index}>
              <NavLink onClick={() => handleSelection(note.title, note.content, index)} active={index === active} label={note.title} />
              <CloseButton onClick={() => deleteNote(index)} title="Delete note" size="xl" iconSize={20} />
            </Flex>
          ))} 
        </Grid.Col>
        <Grid.Col span={2}>
          <Switch label="Toggle Editor / Markdown Preview"  checked={checked} onChange={(event) => setChecked(event.currentTarget.checked)}/>

          <Divider my="sm" />

          {checked === false && (
            <div>
              <TextInput mb={5} />
              <Textarea minRows={10} />
            </div>
          )}
          {checked && (
            <Paper shadow="lg" p={10}>
              <Text fz="xl" fw={500} tt="capitalize">{title}</Text>

              <Divider my="sm" />

              <Markdown>{content}</Markdown>
            </Paper>
          )}
        </Grid.Col>
      </Grid>
    </div>
  )
}

export default App

Il y a beaucoup de code ici, alors explorons-le petit à petit.

Importation des packages nécessaires

Au début, nous importons tous les packages nécessaires comme suit :

  • Analyseur Markdown
  • Composants de la mantine
  • un crochet Mantine
  • Icônes
  • API Taurus
import { useState } from 'react'
import Markdown from 'marked-react'

import { ThemeIcon, Button, CloseButton, Switch, NavLink, Flex, Grid, Divider, Paper, Text, TextInput, Textarea } from '@mantine/core'
import { useLocalStorage } from '@mantine/hooks'
import { IconNotebook, IconFilePlus, IconFileArrowLeft, IconFileArrowRight } from '@tabler/icons'

import { save, open, ask } from '@tauri-apps/api/dialog'
import { writeTextFile, readTextFile } from '@tauri-apps/api/fs'
import { sendNotification } from '@tauri-apps/api/notification'

Configuration du stockage et des variables de l’application

Dans la partie suivante, nous utilisons le useLocalStorage crochet pour configurer le stockage des notes.

Nous définissons également quelques variables pour le titre et le contenu de la note actuelle, et deux autres pour déterminer quelle note est sélectionnée (active) et si l’aperçu Markdown est activé (checked).

Enfin, nous créons une fonction utilitaire pour gérer la sélection d’une note. Lorsqu’une note est sélectionnée, elle met à jour les propriétés de la note actuelle en conséquence :

const [notes, setNotes] = useLocalStorage({ key: "my-notes", defaultValue: [  {
  "title": "New note",
  "content": ""
}] })

const [active, setActive] = useState(0)
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const [checked, setChecked] = useState(false)

const handleSelection = (title: string, content: string, index: number) => {
  setTitle(title)
  setContent(content)
  setActive(index)
}

Ajout de la fonctionnalité d’ajout/suppression de note

Les deux fonctions suivantes servent à ajouter/supprimer une note.

addNote() insère un nouvel objet note dans le notes déployer. Il utilise handleSelection() pour sélectionner automatiquement la nouvelle note après l’avoir ajoutée. Et enfin il met à jour les notes. La raison pour laquelle nous utilisons l’opérateur de propagation ici est que sinon l’état ne sera pas mis à jour. De cette façon, nous forçons l’état à se mettre à jour et le composant à restituer, afin que les notes s’affichent correctement :

const addNote = () => {
  notes.splice(0, 0, {title: "New note", content: ""})
  handleSelection("New note", "", 0)
  setNotes([...notes])
}

const deleteNote = async (index: number) => {
  let deleteNote = await ask("Are you sure you want to delete this note?", {
    title: "My Notes",
    type: "warning",
  })
  if (deleteNote) {
    notes.splice(index,1)
    if (active >= index) {
      setActive(active >= 1 ? active - 1 : 0)
    }
    if (notes.length >= 1) {
      setContent(notes[index-1].content)
    } else {
      setTitle("")
      setContent("")
    } 
    setNotes([...notes]) 
  }
}

deleteNote() utilise le ask boîte de dialogue pour confirmer que l’utilisateur souhaite supprimer la note et qu’il n’a pas cliqué accidentellement sur le bouton de suppression. Si l’utilisateur confirme la suppression (deleteNote = true) puis le if instruction est exécutée :

  • la note est supprimée du notes déployer
  • le active la variable est mise à jour
  • le titre et le contenu de la note actuelle sont mis à jour
  • le notes le tableau est mis à jour

Création du modèle JSX

Dans la section modèle, nous avons deux colonnes.

Dans la première colonne, nous créons le logo et le nom de l’application, ainsi que des boutons pour ajouter, importer et exporter des notes. Nous faisons également une boucle à travers le notes tableau pour rendre les notes. Ici on utilise handleSelection() à nouveau pour mettre à jour correctement les propriétés de la note actuelle lorsqu’un lien de titre de note est cliqué :

<Grid.Col span="auto">
  <Flex gap="xl" justify="flex-start" align="center" wrap="wrap">
    <Flex>
      <ThemeIcon size="lg" variant="gradient" gradient={{ from: "teal", to: "lime", deg: 90 }}>
        <IconNotebook size={32} />
      </ThemeIcon>
      <Text color="green" fz="xl" fw={500} ml={5}>My Notes</Text>
    </Flex>
    <Button onClick={addNote} leftIcon={<IconFilePlus />}>Add note</Button>
    <Button.Group>
      <Button variant="light" leftIcon={<IconFileArrowLeft />}>Import</Button>
      <Button variant="light" leftIcon={<IconFileArrowRight />}>Export</Button>
    </Button.Group>
  </Flex>

  <Divider my="sm" />

  {notes.map((note, index) => (
    <Flex key={index}>
      <NavLink onClick={() => handleSelection(note.title, note.content, index)} active={index === active} label={note.title} />
      <CloseButton onClick={() => deleteNote(index)} title="Delete note" size="xl" iconSize={20} />
    </Flex>
  ))} 
</Grid.Col>

Dans la deuxième colonne, nous ajoutons un bouton bascule pour basculer entre les modes d’édition de note et de prévisualisation. En mode édition, il y a une entrée de texte pour le titre de la note actuelle et une zone de texte pour le contenu de la note actuelle. En mode aperçu, le titre est rendu par Mantine’s Text composant, et le contenu est rendu par le marked-reactc’est Markdown composant:

<Grid.Col span={2}>
  <Switch label="Toggle Editor / Markdown Preview"  checked={checked} onChange={(event) => setChecked(event.currentTarget.checked)}/>

  <Divider my="sm" />

  {checked === false && (
    <div>
      <TextInput mb={5} />
      <Textarea minRows={10} />
    </div>
  )}
  {checked && (
    <Paper shadow="lg" p={10}>
      <Text fz="xl" fw={500} tt="capitalize">{title}</Text>

      <Divider my="sm" />

      <Markdown>{content}</Markdown>
    </Paper>
  )}
</Grid.Col>

Phew! C’était beaucoup de code. L’image ci-dessous montre à quoi notre application devrait ressembler à ce stade.

L'application est prête à ajouter des notes

Super! Nous pouvons ajouter et supprimer des notes maintenant, mais il n’y a aucun moyen de les modifier. Nous ajouterons cette fonctionnalité dans la section suivante.

Ajouter le titre d’une note et la fonctionnalité de mise à jour du contenu

Ajoutez le code suivant après le deleteNote() fonction:

const updateNoteTitle = ({ target: { value } }: { target: { value: string } }) => {
  notes.splice(active, 1, { title: value, content: content })
  setTitle(value)
  setNotes([...notes])
}

const updateNoteContent = ({target: { value } }: { target: { value: string } }) => {
  notes.splice(active, 1, { title: title, content: value })
  setContent(value)
  setNotes([...notes])
}

Ces deux fonctions remplacent respectivement le titre et/ou le contenu de la note en cours. Pour les faire fonctionner, nous devons les ajouter dans le modèle :

<TextInput value={title} onChange={updateNoteTitle} mb={5} />
<Textarea value={content} onChange={updateNoteContent} minRows={10} />

Désormais, lorsqu’une note est sélectionnée, son titre et son contenu seront affichés respectivement dans le texte d’entrée et la zone de texte. Lorsque nous modifions une note, son titre sera mis à jour en conséquence.

J’ai ajouté plusieurs notes pour montrer à quoi ressemblera l’application. L’application avec une note sélectionnée et son contenu est illustrée ci-dessous.

Notes ajoutées à notre application

L’image ci-dessous montre l’aperçu de notre note.

Aperçu de la note

Et l’image suivante montre la boîte de dialogue de confirmation qui s’affiche lors de la suppression de la note.

Boîte de dialogue de suppression de note

Super! La dernière chose dont nous avons besoin pour rendre notre application vraiment cool est d’ajouter une fonctionnalité pour exporter et importer les notes de l’utilisateur sur le disque dur du système.

Ajout de fonctionnalités pour importer et exporter des notes

Ajoutez le code suivant après le updateNoteContent() fonction:

const exportNotes = async () => {
  const exportedNotes = JSON.stringify(notes)
  const filePath = await save({
    filters: [{
      name: "JSON",
      extensions: ["json"]
    }]
  })
  await writeTextFile(`${filePath}`, exportedNotes)
  sendNotification(`Your notes have been successfully saved in ${filePath} file.`)
}

const importNotes = async () => {
  const selectedFile = await open({
    filters: [{
      name: "JSON",
      extensions: ["json"]
    }]
  })
  const fileContent = await readTextFile(`${selectedFile}`)
  const importedNotes = JSON.parse(fileContent)
  setNotes(importedNotes)
}

Dans la première fonction, nous convertissons les notes en JSON. Ensuite on utilise le save boîte de dialogue pour enregistrer les notes. Ensuite, nous utilisons le writeTextFile() fonction pour écrire le fichier physiquement sur le disque. Enfin, nous utilisons le sendNotification() fonction pour informer l’utilisateur que les notes ont été enregistrées avec succès et également où elles ont été enregistrées.

Dans la deuxième fonction, nous utilisons le open boîte de dialogue pour sélectionner un fichier JSON, contenant des notes, à partir du disque. Ensuite, le fichier est lu avec le readTextFile() fonction, son contenu JSON est converti en objet, et enfin le stockage des notes est mis à jour avec le nouveau contenu.

La dernière chose que nous devons faire est de changer le modèle pour utiliser les fonctions ci-dessus :

<Button variant="light" onClick={importNotes} leftIcon={<IconFileArrowLeft />}>Import</Button>
<Button variant="light" onClick={exportNotes} leftIcon={<IconFileArrowRight />}>Export</Button>

Voici ce que le final App.tsx déposer devrait ressembler.

Dans les captures d’écran suivantes, vous pouvez voir le Enregistrer sous et Ouvrir les boîtes de dialogue et la notification système apparaissant sous forme de notes sont enregistrées.

le bouton enregistrer sous

notification de l'endroit où les fichiers ont été enregistrés

bouton pour ouvrir les fichiers

Bravo! Vous venez de créer une application de bureau de prise de notes entièrement fonctionnelle avec la puissance de Tauri.

Créer l’application

Maintenant, si tout fonctionne bien et que vous êtes satisfait du résultat final, vous pouvez créer l’application et obtenir un package d’installation pour votre système d’exploitation. Pour ce faire, exécutez la commande suivante :

Conclusion

Dans ce tutoriel, nous avons exploré ce qu’est Tauri, pourquoi c’est un meilleur choix pour créer des applications de bureau natives par rapport à Electron, et enfin comment créer une application Tauri simple mais entièrement fonctionnelle.

J’espère que vous avez apprécié ce court voyage autant que moi. Pour plonger plus profondément dans le monde de Tauri, découvrez son Documentation et continuez à expérimenter avec ses fonctionnalités puissantes.

Lecture connexe :




Source link