Hi again, coders! In the first part of this short series we saw the creation and operation of a desktop application to store and encrypt our passwords made with the Wails framework. We also made a description of the Go backend and how we bind it to the frontend side.
In this part, we are going to deal with the user interface. As we stated in that post, Wails allows us to use any web framework we like, even Vanilla JS, to build our GUI. As I said, it seems that the creators of Wails have a preference for Svelte, because they always mention it as their first choice. The Wails CLI (in its current version) when we ask to create a project with Svelte+Typescript (wails init -n myproject -t svelte-ts
) generates the scaffolding with Svelte3. As I already told you, if you prefer to use Svelte5 (and its new features) I have a bash script that automates its creation (in any case, you have to have the Wails CLI installed). In addition, it adds Taildwindcss+Daisyui which seems to me a perfect combination for the interface design.
The truth is that I had worked first with Vanilla Js and Vue, then with React, and even with that strange library that for many is HTMX (which I have to say that I love ❤️). But Svelte makes you fall in love from the beginning, and I have to say that it was while experimenting with Wails that I used it for the first time (and I promise to continue using it…). But as comfortable as a web framework is, we must remind backend developers that the frontend is not that easy 😀!!
But let's get to the point.
I - A look at the frontend structure
If you have used any web framework, you will quickly recognize that the Wails CLI uses ViteJs under the hood:
...
.
├── index.html
├── package.json
├── package.json.md5
├── package-lock.json
├── postcss.config.js
├── README.md
├── src
│ ├── App.svelte
│ ├── assets
│ │ ├── fonts
│ │ │ ├── nunito-v16-latin-regular.woff2
│ │ │ └── OFL.txt
│ │ └── images
│ │ └── logo-universal.png
│ ├── lib
│ │ ├── BackBtn.svelte
│ │ ├── BottomActions.svelte
│ │ ├── EditActions.svelte
│ │ ├── EntriesList.svelte
│ │ ├── Language.svelte
│ │ ├── popups
│ │ │ ├── alert-icons.ts
│ │ │ └── popups.ts
│ │ ├── ShowPasswordBtn.svelte
│ │ └── TopActions.svelte
│ ├── locales
│ │ ├── en.json
│ │ └── es.json
│ ├── main.ts
│ ├── pages
│ │ ├── About.svelte
│ │ ├── AddPassword.svelte
│ │ ├── Details.svelte
│ │ ├── EditPassword.svelte
│ │ ├── Home.svelte
│ │ ├── Login.svelte
│ │ └── Settings.svelte
│ ├── style.css
│ └── vite-env.d.ts
├── svelte.config.js
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── wailsjs
├── go
│ ├── main
│ │ ├── App.d.ts
│ │ └── App.js
│ └── models.ts
└── runtime
├── package.json
├── runtime.d.ts
└── runtime.js
...
If you have used any web framework generated by Vite you will not be surprised by its configuration files. Here I use Svelte5 (plus the configuration of Taildwindcss+Daisyui) which is what generates my own bash script, as I have already told you. We also use TypeScript, which will facilitate the development of the frontend, so you can also see its configurations.
But the important thing in this explanation is the content of the wailsjs folder. This is where the compilation done by Wails has done its magic. The go
subfolder is where the methods "translated" to Js/Ts of the backend part that has to interact with the frontend are stored. For example, in main/App.js
(or its TypeScript version, main/App.d.ts
) there are all the exported (public) methods of the App
structure:
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {models} from '../models';
export function AddPasswordEntry(arg1:string,arg2:string,arg3:string):Promise<string>;
export function CheckMasterPassword(arg1:string):Promise<boolean>;
export function DeleteEntry(arg1:string):Promise<void>;
export function Drop():Promise<void>;
export function GetAllEntries():Promise<Array<models.PasswordEntry>>;
export function GetEntryById(arg1:string):Promise<models.PasswordEntry>;
export function GetLanguage():Promise<string>;
export function GetMasterPassword():Promise<boolean>;
export function GetPasswordCount():Promise<number>;
export function SaveLanguage(arg1:string):Promise<void>;
export function SaveMasterPassword(arg1:string):Promise<string>;
export function UpdateEntry(arg1:models.PasswordEntry):Promise<boolean>;
All of them return a promise. If the promise "wraps" some Go structure used as return type or the respective function takes an argument type, there will be a module (models.ts
, typed in this case, because we use TypeScript) that contains the class corresponding to the Go structure and its constructor in a namespace.
Additionally, the runtime
subfolder contains all the methods from Go's runtime
package that allow us to manipulate the window and events listened to or emitted from or to the backend, respectively.
The src
folder contains the files that will be compiled by Vite to save them in "frontend/dist" (and embedded in the final executable), as in any web application. Note that, since we use Tailwindcss, style.css
contains the basic Tailwind configuration plus any CSS classes we need to use. Also, as an advantage of using web technology for the interface, we can easily use one or more fonts (folder assets/fonts
) or exchange them.
To finish with this overview, note that when we compile in development mode (wails dev
), in addition to allowing us to hot reloading, we can not only observe the changes made (both in the backend and in the frontend) in the application window itself, but also in a web browser through the address http://localhost:34115
, since a webserver is started. This allows you to use your favorite browser development extensions. Although it must be said that Wails himself provides us with some very useful dev tools, when we right-click on the application window (only in development mode) and choose "Inspect Element":
II - And now… a dive into HTML, CSS and JavaScript 🤿
/* package.json */
...
},
"dependencies": {
"svelte-copy": "^2.0.0",
"svelte-i18n": "^4.0.1",
"svelte-spa-router": "^4.0.1",
"sweetalert2": "^11.14.5"
}
...
As you can see, there are 4 JavaScript packages I've added to Svelte (apart from the already mentioned Tailwindcss+Daisyui):
- svelte-copy, to make it easier to copy username and password to clipboard.
- svelte-i18n, for i18n handling, i.e. allowing the user to change the application's language.
-
svelte-spa-router, a small routing library for Svelte, which makes it easier to change views in the application window, since it's not worth it, in this case, to use the "official" routing provided by
SvelteKit
. - sweetalert2, basically use it to create modals/dialog boxes easily and quickly.
The entry point of every SPA is the main.js
(or main.ts
) file, so let's start with that:
/* main.ts */
import { mount } from 'svelte'
import './style.css'
import App from './App.svelte'
import { addMessages, init } from "svelte-i18n"; // ⇐ ⇐
import en from './locales/en.json'; // ⇐ ⇐
import es from './locales/es.json'; // ⇐ ⇐
addMessages('en', en); // ⇐ ⇐
addMessages('es', es); // ⇐ ⇐
init({
fallbackLocale: 'en', // ⇐ ⇐
initialLocale: 'en', // ⇐ ⇐
});
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app
I've highlighted the things I've added to the skeleton generated by the Wails CLI. The svelte-i18n library requires that JSON files containing translations be registered in the main.js/ts
file, at the same time as setting the fallback/initial language (although as we'll see, that will be manipulated later based on what the user has selected as their preferences). The JSON files containing the translations are in the format:
/* frontend/src/locales/en.json */
{
"language": "Language",
"app_title": "Nu-i uita • minimalist password store",
"select_directory": "Select the directory where to save the data export",
"select_file": "Select the backup file to import",
"master_password": "Master Password 👇",
"generate": "Generate",
"insert": "Insert",
"login": "Login",
...
}
/* frontend/src/locales/es.json */
{
"language": "Idioma",
"app_title": "Nu-i uita • almacén de contraseñas minimalista",
"select_directory": "Selecciona el directorio donde guardar los datos exportados",
"select_file": "Selecciona el archivo de respaldo que deseas importar",
"master_password": "Contraseña Maestra 👇",
"generate": "Generar",
"insert": "Insertar",
"login": "Inciar sesión",
...
}
I find this library's system to be easy and convenient for facilitating translations of Svelte applications (you can consult its documentation for more details):
<script>
import { _ } from "svelte-i18n";
</script>
<svelte:head>
<title>{$_("app_title")}</title>
</svelte:head>
You can also use sites like this one, which will help you translate JSON files into different languages. However, the problem is that when you fill your .svelte files with $format
you have to manually keep track of them, which is tedious and error-prone. I don't know of any way to automate this task, if anyone knows, I'd be interested if you'd let me know 🙏… Otherwise, I'd have to think of some kind of script to do that job.
The next step in building the interface, as in any Svelte application, is the App.svelte
file:
/* App.svelte */
<script lang="ts">
import Router from "svelte-spa-router";
import Home from "./pages/Home.svelte";
import Login from "./pages/Login.svelte";
import About from "./pages/About.svelte";
import AddPassword from "./pages/AddPassword.svelte";
import Details from "./pages/Details.svelte";
import EditPassword from "./pages/EditPassword.svelte";
import Settings from "./pages/Settings.svelte";
import { EventsEmit } from "../wailsjs/runtime/runtime";
const routes = {
"/": Login,
"/home": Home,
"/about": About,
"/add": AddPassword,
"/details/:id": Details,
"/edit/:id": EditPassword,
"/settings": Settings,
};
const onKeyDown = (e: KeyboardEvent) =>
e.key === "Escape" ? EventsEmit("quit") : false;
</script>
<main class="overflow-hidden">
<Router {routes} />
</main>
<svelte:window on:keydown={onKeyDown} />
3 things to comment here. The first is the use of the svelte-spa-router
library (for more details see its doc here). For the simple purpose of changing views in a desktop application, this library more than fulfills its purpose. With the 7 views or pages we create a dictionary (or JavaScript object) that associates routes with views. Then this dictionary is passed as props to the Router component of svelte-spa-router
. It's that simple. As we will see later, through programmatic navigation or through user action we can easily change views.
The other thing is that I added a little gadget: when the user presses the Escape
key the application closes (on the Settings page a tip clarifies to the user that this key closes the application). Svelte actually makes the job a lot easier, because this simple line: <svelte:window on:keydown={onKeyDown} />
catches the Keyboard
event from the DOM triggering the execution of the onKeyDown
function which in turn emits a Wails event (which we call "quit") which is listened to in the backend and when received there, closes the application. Since App.svelte
is the component that encompasses the entire application, this is the right place to put the code for this action.
The last thing to clarify is why the HTML main tag carries Tailwind's overflow-hidden
utility class. Since we're going to use an animation where components appear to enter from the right, which momentarily "increases" the width of the window, overflow-hidden
prevents an ugly horizontal scrollbar from appearing.
The first view the user sees when opening the application is the Login view/page. Its logic is similar to that of a login page in any web application. Let's first look at the logic used for the views animations, because it is the same as that followed on the rest of the pages:
/* Login.svelte */
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
...
let mounted = false;
...
onMount(() => {
mounted = true;
...
};
...
</script>
{#if mounted}
<div
in:fly={{ x: 75, duration: 1200 }}
out:fade={{ duration: 200 }}
class="flex h-screen"
>
...
</div>
{/if}
The animation requires declaring a variable (state, let mount = false;
) which is initially set to false. When the component is mounted, a lifecycle hook (onMount
, similar to React) sets it to true and the animation can now begin. An entrance from the right of 1200 milliseconds duration is used (in:fly={{ x: 75, duration: 1200 }}
) and a fade (out:fade={{ duration: 200 }}
) of 200 milliseconds duration. Simple thanks to Svelte.
When setting up the Login view we also need to know if the user is already registered in the database or if it is the first time he/she enters the application:
/* Login.svelte */
...
let isLogin = false;
...
onMount(() => {
GetMasterPassword().then((result) => {
isLogin = result;
// console.log("Master password exists in DB:", isLogin);
});
...
};
Here we make use of GetMasterPassword
which is a binding generated automatically when compiling the application and which was declared as a public method of the struct App
(see the first part of this series). This function queries the database and, in case there is a master password registered in it, it considers the user as already registered (it returns a promise that wraps a boolean value), asking him to enter said password to allow him access to the rest of the views. If there is no master password in the database, the user is considered as "new" and what is asked is that he generates his own password to enter the application for the first time.
Finally, when mounting the Login.svelte
component we do something that is important for the rest of the application. Although the svelte-i18n
library forces us to declare the initial language code, as we have already seen, when mounting Login.svelte
we ask the database (using the GetLanguage
binding) to check if there is a language code saved. In case the database returns an empty string, that is, if there is no language configured as the user's preference, svelte-i18n
will use the value configured as initialLocale
. If instead there is a language configured, that language will be set (locale.set(result);
) and the "change_titles" event will be emitted, to which the translated titles of the title bar and native dialogs of the app will be passed for the backend to handle:
/* Login.svelte */
<script lang="ts">
import { onMount } from "svelte";
...
import { _, locale } from "svelte-i18n";
import {
...
GetLanguage,
...
} from "../../wailsjs/go/main/App";
...
import { EventsEmit } from "../../wailsjs/runtime/runtime";
...
onMount(() => {
...
GetLanguage().then((result) => {
locale.set(result);
EventsEmit(
"change_titles",
$_("app_title"),
$_("select_directory"),
$_("select_file"),
);
});
...
};
...
</script>
The following is the logic for handling the login:
/* Login.svelte */
<script lang="ts">
...
import { _, locale } from "svelte-i18n";
import {
...
GetMasterPassword,
SaveMasterPassword,
} from "../../wailsjs/go/main/App";
import { push } from "svelte-spa-router";
// states and local variables
let mounted = false,
inputRef: HTMLInputElement | null = null,
isLogin = false,
show = false,
newPassword = "",
visible = false,
toast = "",
tmId1 = 0,
tmId2 = 0;
...
const onLogin = () => {
if (newPassword.length < 6 || !isAscii(newPassword)) {
toast = $_("password_too_short_or_non_ascii_chars");
visible = true;
tmId1 = setTimeout(() => {
toast = "";
visible = false;
}, 2000);
newPassword = "";
inputRef?.focus();
return;
} else if (!isLogin) {
SaveMasterPassword(newPassword).then((result) => {
// console.log("PASSWORD_ID:", result);
result ? push("/home") : false;
});
return;
}
CheckMasterPassword(newPassword).then((result) => {
if (result) {
push("/home");
} else {
toast = $_("wrong_password");
visible = true;
tmId2 = setTimeout(() => {
toast = "";
visible = false;
}, 2000);
newPassword = "";
inputRef?.focus();
}
});
};
const isAscii = (str: string): boolean => /^[\x00-\x7F]+$/.test(str);
</script>
Simply put: newPassword
, the state bound to the input that gets what the user types, is first checked by onLogin
to see if it has at least 6 characters and that all of them are ASCII characters, i.e. they are only 1 byte long (see the reason for that in part I of this series) by this little function const isAscii = (str: string): boolean => /^[\x00-\x7F]+$/.test(str);
. If the check fails the function returns and displays a warning toast to the user. Afterwards, if there is no master password saved in the database (isLogin
= false), whatever the user types is saved by the SaveMasterPassword
function (a binding generated by Wails); If the promise is resolved successfully (returns a uuid string as the Id of the record stored in the database), the user is taken to the home
view by the svelte-spa-router
library's push
method. Conversely, if the password passes the check for length and absence of non-ASCII characters and there is a master password in the DB (isLogin
= true) then the CheckMasterPassword
function verifies its identity against the stored one and either takes the user to the home
view (promise resolved with true) or a toast is shown indicating that the entered password was incorrect.
The central view of the application and at the same time the most complex is the home
view. Its HTML is actually subdivided into 3 components: a top button bar with a search input (TopActions
component), a bottom button bar (BottomActions
component) and a central area where the total number of saved password entries or the list of these is displayed using a scrollable window (EntriesList
component):
/* Home.svelte */
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { _ } from "svelte-i18n";
import BottomActions from "../lib/BottomActions.svelte";
import TopActions from "../lib/TopActions.svelte";
import { GetPasswordCount } from "../../wailsjs/go/main/App";
import EntriesList from "../lib/EntriesList.svelte";
let mounted: boolean = false,
count: number = 0,
showList: boolean = false,
searchTerms: string = "";
onMount(() => {
mounted = true;
GetPasswordCount().then((result) => (count = result));
});
</script>
{#if mounted}
<div
in:fly={{ x: 75, duration: 1200 }}
out:fade={{ duration: 200 }}
class="flex h-screen relative"
>
<TopActions bind:isEntriesList={showList} bind:search={searchTerms} />
{#if !showList}
<h1 class="text-lg font-light m-auto">
{count} {$_("home_title")}
</h1>
{:else}
<EntriesList bind:listCounter={count} bind:search={searchTerms} />
{/if}
<BottomActions />
</div>
{/if}
Let's take a look at the TopActions
and EntriesList
components since they are both very closely related. And they are, especially since their props are states of the parent component. This is where that new feature of Svelte5 comes into play: runes. Both components take props declared with the $bindable
rune; this means that data can also flow up from child to parent. A diagram may make it clearer:
For example, in the TopActions
component if the user clicks on the "Entries list" button, this is executed:
/* TopActions.svelte */
onclick={() => {
search = ""; // is `searchTerms` in the parent component
isEntriesList = !isEntriesList; // is `showList` in the parent component
}}
That is, it makes the search state (searchTerms
) an empty string, so that if there are any search terms it is reset and thus the entire list is shown. And on the other hand, it toggles the showList
state (props isEntriesList
in TopActions
) so that the parent component shows or hides the list.
As we can see in the diagram above, both child components share the same props with the parent's searchTerms
state. The TopActions
component captures the input from the user and passes it as state to the parent component Home
, which in turn passes it as props to its child component EntriesList
.
The main logic of displaying the full list or a list filtered by the search terms entered by the user is carried out, as expected, by the EntriesList
component:
/* EntriesList.svelte */
<script lang="ts">
import { models } from "../../wailsjs/go/models";
import {
GetAllEntries,
GetPasswordCount,
DeleteEntry,
} from "../../wailsjs/go/main/App";
import { onDestroy, onMount } from "svelte";
import { _ } from "svelte-i18n";
import { push } from "svelte-spa-router";
import { showSuccess, showWarning } from "./popups/popups";
let {
listCounter = $bindable(),
search = $bindable(),
}: {
listCounter: number;
search: string;
} = $props();
let entries: models.PasswordEntry[] = $state([]);
onMount(() => {
GetAllEntries().then((result) => {
// console.log("SEARCH:", search);
if (search) {
const find = search.toLowerCase();
entries = result.filter(
(item) =>
item.Username.toLowerCase().includes(find) ||
item.Website.toLowerCase().includes(find),
);
} else {
entries = result;
}
});
});
onDestroy(() => (search = ""));
const showAlert = (website: string, id: string) => {
const data: string[] = [
`${$_("alert_deleting_password")} "${website}."`,
`${$_("alert_confirm_deleting")}`,
];
showWarning(data).then((result) => {
if (result.value) {
DeleteEntry(id).then(() => deleteItem(id));
showSuccess($_("deletion_confirm_msg"));
}
});
};
const deleteItem = (id: string): void => {
let itemIdx = entries.findIndex((x) => x.Id === id);
entries.splice(itemIdx, 1);
entries = entries;
GetPasswordCount().then((result) => (listCounter = result));
};
</script>
As we said, 2 props are received (listCounter
and search
) and a state is maintained (let entries: models.PasswordEntry[] = $state([]);
). When the component is mounted at the user's request, the backend is asked for the complete list of saved password entries. If there are no search terms, they are stored in the state; if there are, a simple filtering of the obtained array is performed and it is saved in the state:
/* EntriesList.svelte */
...
onMount(() => {
GetAllEntries().then((result) => {
// console.log("SEARCH:", search);
if (search) {
const find = search.toLowerCase();
entries = result.filter(
(item) =>
item.Username.toLowerCase().includes(find) ||
item.Website.toLowerCase().includes(find),
);
} else {
entries = result;
}
});
});
...
In the displayed list, the user can perform 2 actions. The first is to display the details of the entry, which is carried out when he clicks on the corresponding button: onclick={() => push(`/details/${entry.Id}`)}
. Basically, we call the push
method of the routing library to take the user to the details view, but passing the Id parameter corresponding to the item in question.
The other action the user can perform is to delete an item from the list. If he clicks on the corresponding button, he will be shown a confirmation popup, calling the showAlert
function. This function in turn calls showWarning
, which is actually an abstraction layer over the sweetalert2
library (all the functions that call the sweetalert2
library are in frontend/src/lib/popups/popups.ts
). If the user confirms the deletion action, the DeleteEntry
binding is called (to delete it from the DB) and, in turn, if the promise it returns is resolved, deleteItem
is called (to delete it from the array stored in the entries
state):
/* EntriesList.svelte */
...
const showAlert = (website: string, id: string) => {
const data: string[] = [
`${$_("alert_deleting_password")} "${website}."`,
`${$_("alert_confirm_deleting")}`,
];
showWarning(data).then((result) => {
if (result.value) {
DeleteEntry(id).then(() => deleteItem(id));
showSuccess($_("deletion_confirm_msg"));
}
});
};
const deleteItem = (id: string): void => {
let itemIdx = entries.findIndex((x) => x.Id === id);
entries.splice(itemIdx, 1);
entries = entries;
GetPasswordCount().then((result) => (listCounter = result));
};
The other component of the Home
view (BottomActions
) is much simpler: it does not receive props and is limited to redirecting the user to various views (Settings
, About
or AddPassword
).
The AddPassword
and EditPassword
views share very similar logic and are similar to the Login
view as well. Both do not allow the user to enter spaces at the beginning and end of what they typed in the text input and follow the same policy as the Login
view of requiring passwords to be at least 6 ASCII characters long. Basically, what sets them apart is that they call the Wails-generated links relevant to the action they need to perform:
/* AddPassword.svelte */
...
AddPasswordEntry(website, username, newPassword).then((result) => {
result ? push("/home") : false;
});
...
/* EditPassword.svelte */
...
UpdateEntry(entry).then((result) => {
result ? push("/home") : false;
});
...
The other view that is somewhat complex is Settings
. This has a Language
component that receives as props languageName
from its parent component (Settings
):
/* Language.svelte */
<script lang="ts">
import { onMount } from "svelte";
import { locale, _ } from "svelte-i18n";
import { SaveLanguage } from "../../wailsjs/go/main/App";
import { EventsEmit } from "../../wailsjs/runtime/runtime";
type language = {
code: string;
name: string;
};
const languages: language[] = [
{ code: "en", name: "English" },
{ code: "es", name: "Español" },
];
let { languageName }: { languageName: string } = $props();
onMount(() => (selected.name = languageName));
let selected = $state({ code: "", name: "" } as language);
const handleChange = (newLocale: language) => {
// console.log("Language selected:", newLocale);
locale.set(newLocale["code"]);
EventsEmit(
"change_titles",
$_("app_title"),
$_("select_directory"),
$_("select_file"),
);
SaveLanguage(newLocale["code"]);
};
</script>
...
The HTML for this component is a single select that handles the user's language choice. In its onchange
event it receives a function (handleChange
) that does 3 things:
- sets the language on the frontend using the
svelte-i18n
library - emits an event ("change_titles") so that the Wails runtime changes the title of the application's title bar and the titles of the Select Directory and Select File dialog boxes in relation to the previous action
- saves the language selected by the user in the DB so that the next time the application is started it will open configured with that language.
Returning to the Settings
view, its entire operation is governed by a series of events that are sent and received to or from the backend. The simplest of all is the Quit
button: when the user clicks on it, a quit
event is triggered and listened to in the backend and the application closes (onclick={() => EventsEmit("quit")}
). A tip informs the user that the Escape
key (shortcut) performs the same action, as we already explained.
The reset button calls a function that displays a popup window:
/* Setting.svelte */
...
const showAlert = () => {
let data: string[] = [
`${$_("alert_delete_all")}`,
`${$_("alert_confirm_deleting")}`,
];
showWarning(data).then((result) => {
if (result.value) {
Drop().then(() => push("/"));
showSuccess($_("alert_delete_confirm_msg"));
}
});
};
...
If the user accepts the action, the Drop
binding is called, which cleans all the collections in the DB, and if the promise it returns is resolved, it sends the user to the Login
view, showing a modal indicating the success of the action.
The other two actions that remain are similar to each other, so let's look at Import Data.
If the user clicks on the corresponding button, an event is emitted (onclick={() => EventsEmit("import_data")}
) which is listened for in the backend. When received, the native Select File dialog box is opened to allow the user to select the backup file. If the user chooses the file, the variable containing the path (fileLocation
) will not contain an empty string and this will trigger an event in the backend ("enter_password") which is now listened for in the frontend to, in turn, display a new popup window asking for the master password used when the export was made. Again, the frontend will emit another event ("password") which carries the master password entered by the user. This new event, when received in the backend, executes the ImportDump
method of the Db
package which performs the work of reading and restoring the data in the DB from the backup file that the user has selected. As a result, a new event ("imported_data") is emitted, which carries the result (successful or unsuccessful) of its execution as attached data. The frontend, when it receives the event, only has to perform 2 tasks:
- if the result was successful, set the language that was saved in the backup file and show a modal indicating the success of the action
- if for whatever reason the import could not be done, show the error and its cause.
All of this is much easier to see in the code logic than to explain with words 😝:
/* Setting.svelte */
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import BackBtn from "../lib/BackBtn.svelte";
import Language from "../lib/Language.svelte";
import { EventsEmit, EventsOn } from "../../wailsjs/runtime/runtime";
import { Drop, GetLanguage } from "../../wailsjs/go/main/App";
import { _, locale } from "svelte-i18n";
import { push } from "svelte-spa-router";
import {
showError,
showQuestion,
showSuccess,
showWarning,
} from "../lib/popups/popups";
let mounted: boolean = false;
onMount(() => {
mounted = true;
const cancelSavedAs = EventsOn("saved_as", (result: string) => {
result.includes("error")
? showError(result)
: showSuccess(`${$_("saved_as")} ${result}`);
});
const cancelEnterPassword = EventsOn("enter_password", async () => {
const { value: password } = await showQuestion(
$_("enter_password"),
);
if (password) {
EventsEmit("password", password);
}
});
const cancelImportedData = EventsOn("imported_data", (res: string) => {
// console.log(res);
if (res === "success") {
GetLanguage().then((result) => {
locale.set(result);
EventsEmit(
"change_titles",
$_("app_title"),
$_("select_directory"),
$_("select_file"),
);
showSuccess($_("import_successful"));
});
} else {
// `${res.replace(/^.{1}/g, res[0].toUpperCase())} !!`
res = res.includes("cannot")
? `${$_("backup_error")}`
: `${$_("invalid_password")}`;
showError(res);
}
});
// canceling listeners
return () => {
cancelSavedAs();
cancelEnterPassword();
cancelImportedData();
// console.log("CANCELING LISTENERS");
};
});
const showAlert = () => {
let data: string[] = [
`${$_("alert_delete_all")}`,
`${$_("alert_confirm_deleting")}`,
];
showWarning(data).then((result) => {
if (result.value) {
Drop().then(() => push("/"));
showSuccess($_("alert_delete_confirm_msg"));
}
});
};
</script>
...
It is worth mentioning that the Wails runtime
function that registers listeners on the frontend (EventsOn
) returns a function, which when called cancels said listener. It is convenient to cancel said listeners when the component is destroyed. Similarly to React the onMount
hook can "clean up" said listeners by making them return a cleanup function that, in this case, will call all the functions returned by EventsOn
that we have taken the precaution of saving in separate variables:
/* Setting.svelte */
...
// canceling listeners
return () => {
cancelSavedAs();
cancelEnterPassword();
cancelImportedData();
};
...
To finish this review of the frontend part of our application, it only remains to say something about the About component. This has little logic since it is limited to displaying information about the application as is usual in an about. It should be said, however, that, as we can see, the view shows a link to the application repository. Obviously, in a normal web page an anchor tag (<a>
) would make us navigate to the corresponding link, but in a desktop application this would not happen if Wails did not have a specific function (BrowserOpenURL
) for this in its runtime:
/* About.svelte */
...
<a
onclick={() => BrowserOpenURL("https://github.com/emarifer/Nu-i-uita")}
class="text-xs font-medium hover:text-sky-500 ease-in duration-300 -m-4"
href="http://"
target="_blank"
rel="noopener noreferrer"
>
https://github.com/emarifer/Nu-i-uita
</a>
...
III - A few words about building the Wails app
If you want to build the application executable by packaging everything, including the application icon and all assets (fonts, images, etc.) just run the command:
$ wails build
This will build the binary into the build/bin
folder. However, for choosing other build options or performing cross-compiling, you may want to take a look at the Wails CLI documentation.
For this application, I think I already mentioned it in the first part of this series, I have only focused on the compilation for Windows and Linux. To perform these tasks (which, due to testing, are repetitive) in a comfortable way I have created some small scripts and a Makefile that "coordinates" them.
The make create-bundles
command creates for the Linux version a .tar.xz
compressed file with the application and a Makefile
that acts as an 'installer' that installs the executable, a desktop entry
to create an entry in the Start Menu and the corresponding application icon. For the Windows version, the binary is simply compressed as a .zip
inside a folder called dist/
.However, if you prefer a cross-platform automated build, Wails has a Github Actions that allows you to upload (default option) the generated artifacts to your repository.
Note that if you use the
make create-bundles
command when running it, it will call the Wails commandswails build -clean -upx
(in the case of Linux) orwails build -skipbindings -s -platform windows/amd64 -upx
(in the case of Windows). The-upx
flag refers to the compression of the binary using the UPX utility that you should have installed on your computer. Part of the secret of the small size of the executable is due to the magnificent compression job that this utility does.
Finally, note that the build scripts automatically add the current repository tag to the About
view and after the build restore its value to the default (DEV_VERSION
).
Phew! These 2 posts ended up being longer than I thought! But I hope you liked them and, above all, that they help you think about new projects. Learning something in programming works like that…
Remember that you can find all the application code in this GitHub repository.
I'm sure I'll see you in other posts. Happy coding 😀!!
Top comments (0)