solarcast-screenclient
v1.6.16
Published
Show relevant information on Zonneplan screens!
Downloads
1
Readme
Screen Client
De native applicatie, de Screen Client, draait alle individuele schermen voor het ontvangen en weergeven van de scherminhoud. Dit is onderdeel van het SolarCast project.
Deze repository bevat de Screen Client zelf en de NPM package Screen Content, verantwoordelijk voor het ophalen en weergeven van de scherminhoud.
Dit document is een uitbreiding op het technisch ontwerp en begint op component (C3) niveau.
Installatie
Volg onderstaande stappen om het project lokaal op te zetten.
- Kloon de repository.
- Open de map met je favoriete editor. Als je geen voorkeur hebt, wordt Visual Studio Code aangeraden met de aanbevolen extensies, beschreven in de workspace.
- Download en installeer NodeJS.
- Installeer de dependencies van Electron:
yarn install
- Kopieer
.env.example
, hernoem deze naar.env
en vul deze in met de juiste gegevens. De applicatie kan geen geheimen bewaren, omdat het gecompileerde JavaScript is. Zet daarom nooit gevoelige gegevens in deze applicatie of in.env
. De client ID wordt gegenereerd aan de server kant, zie de installatie. Vergeet niet om deContent-Security-Policy
bij te werken inindex.html
. - Bouw de Electron applicatie:
# Development mode with development server, watch the code yarn dev # Development mode with production server, watch the code yarn start # Production mode yarn prod # Publish for running operating system yarn run publish
Dependencies
Voor de complete lijst met dependecies, zie package.json
of deze pagina. De meest belangrijke dependencies staan geformuleerd in onderstaande lijst. De overige dependencies zijn nodig om deze dependencies te laten werken.
- React, het JavaScript framework waarmee de pagina's gerenderd worden.
- Electron, de JavaScript wrapper die van een native applicatie van de browser maakt.
- Electron Forge, voor het compilen, bouwen en publiceren van de Electron applicatie.
- TypeScript, een uitbreiding op JavaScript voor de ontwikkelaars om JavaScript strongly-typed te maken.
- ESLint, linting van de code voor developers.
- Jest, het uitvoeren van unittests en React render tests.
- Spectron, het uitvoeren van end-2-end tests waarbij de applicatie wordt bediend alsof de gebruiker dit doet.
Inhoudsopgave
C3 Componenten
De component schermcliënt bestaat uit verschillende frameworks om aan de functionele eisen te voldoen en de code modulair op te kunnen bouwen. Onderstaande paragrafen beschrijven de structuur van de applicatie en hoe deze gebruikt wordt.
De package, solarcast-screen-content
, roept de scherminhoud op als er een geldige access token meegegeven is. Deze wordt gebruikt door zowel deze applicatie als de front-end, te vinden in de andere repository.
Structuur
Onderstaande boom beschrijft de structuur van het project. Dit lijkt complex, maar uiteindelijk staat er alleen zelfgeschreven code in de src
en tests
map. Zie flow om te zien hoe deze structuur doorlopen wordt.
solarcast-desktop-client
├───.github
│ └───workflows
| # Locatie waar de GitHub actions voor
| # CI worden gezet.
|
├───.webpack
│ # Locatie waar de developmentversies van de
| # applicatie worden gegenereerd.
│
├───coverage
| # Testrapportages met de code coverage.
|
├───docs
| # Aanvulling op de documentatie.
|
├───node_modules
| # Locatie van alle geïnstalleerde dependencies.
|
├───out
│ # Locatie waar de productieversies van de
| # applicatie worden gegenereerd.
│
├───patches
| # Locatie waar de patches staan van packages
| # die na de installatie worden uitgevoerd.
|
├───src
│ ├───Assets
| | # Alle statische assets die gekopieerd
| | # moeten worden voor publicatie.
| |
│ ├───Components
| | # Alle React componenten die gebruikt
| | # worden door meerdere pagina's.
| |
│ ├───Context
| | # Alle contexten verantwoordelijk voor
| | # het delen van een state tussen de
| | # React componenten.
| |
│ ├───Electron
| | # Alle code gerelateerd aan Electron die
| | # opgeroepen wordt door index.ts.
| |
│ ├───Hooks
| | # Alle React hooks die hergebruikt worden
| | # door de componenten voor het vervullen
| | # van een enkel doel.
| |
│ ├───Models
| | # Alle (TypeScript) models die structuur
| | # aan de code geven, exclusief props en state.
| |
│ ├───Package
| | # Package dat gebruikt wordt door de cliënt
| | # en de front-end voor het weergeven van de
| | # scherminhoud.
| |
│ ├───Pages
| | # Alle unieke pagina's die geladen worden
| | # door React.
| |
│ ├───Services
| | # Alle gedeelde services voor React
| | # zonder JSX structuur.
| |
│ └───Store
| # Alle variabelen die opgeslagen worden
| # op een centrale plek.
|
└───tests
# Locatie van alle end-2-end tests.
De structuur van de Package
map is hetzelfde als de structuur van de rest van de React applicatie: Components
, Models
, Pages
en Services
.
Complexere componenten die vaak meer dan één bestand nodig hebben, worden in een aparte map opgeslagen. De naam van het hoofdbestand is dan index.ts(x)
. Deze wordt automatisch geïmporteerd als er geen bestandsnaam gedefinieerd is. Alle hulpbestanden zoals styling en extra componenten staan dan in de map bij het hoofdcomponent.
Continuïteit
Bij een normale applicatie voert een gebruiker een actie uit en ontvangt daarna een resultaat. Deze applicatie moet zelfstandig kunnen draaien, dus zonder menselijke interactie, zie het functioneel ontwerp voor meer informatie hierover. In technische termen betekent dit dat de applicatie zich altijd in een continue lus bevindt. Dit introduceert twee nieuwe gevaren:
- De lus kan verbroken worden, waardoor de applicatie in een bevroren staat komt en er op dat moment menselijke interactie nodig is om de applicatie weer draaiende te krijgen.
- De recursieve lussen roepen zichzelf meer dan één keer aan, waardoor de applicatie overbelast raakt. Als dit proces eenmaal gestart is, is het kwadratisch.
Beide gevaren moeten voorkomen worden. Vandaar dat het extreem belangrijk is dat een actie de garbage van de vorige actie opruimt. Een voorbeeld waar het mis kan gaan, zijn de timers. Als een timer opnieuw ingesteld wordt, moet de timer die daarvoor stond altijd (preventief) geannuleerd worden. Dit voorkomt dubbele timers waardoor kwadratische recursie ontstaat. Zie gevaren voor meer informatie hierover.
Om een duidelijk beeld te krijgen van de applicatie staat hieronder een activity diagram met daarin een abstracte weergave. Elke actie roept een bestaande actie aan. Er is geen actie waarbij de lus van het programma eindigt.
Het programma begint onderaan met het opstarten. Er worden twee lussen gestart: De linkerlus is die van de renderer voor het weergeven van de views en de rechterlus is die van het hoofdproces voor het bijwerken van de applicatie. Alleen de lus van de renderer wordt hier besproken, omdat het bijwerken van de applicatie concreet gedocumenteerd staat.
Allereerst moet de renderer de gedeelde environment variabelen ophalen vanuit het hoofdproces van Electron. Dit is een asyncroon proces, omdat het ophalen van deze variabelen het weergeven van het logo niet mag blokkeren. De applicatie geeft dus het logo weer totdat deze de benodigde informatie binnen heeft. Als de URL en de Pusher gegevens binnen zijn, controleert de applicatie of de vereiste autorisatiegegevens nog beschikbaar zijn in Local Storage. Dit zijn de access token voor het autoriseren in combinatie met de device code voor het identificeren.
Wanneer de gebruiker de applicatie voor het eerst opstart, zullen deze tokens uiteraard niet beschikbaar zijn. De gebruiker komt in de Connect lus, aangegeven in het diagram met paars. Deze staat in details beschreven in het onderzoek. Uiteindelijk levert dit proces als het goed is een geldige access token en device code.
Deze tokens worden gebruikt om eerst het apparaat te identificeren. De device code die het apparaat ontvangt, is namelijk een geëncrypte variant van de device ID waaruit niet direct het scherm afgeleid kan worden. Het identificeren kan drie resultaten hebben:
- Scherm is gevonden. Het apparaat weet nu met welk scherm deze verbonden is en kan beginnen met het ophalen van de content.
- Scherm is nog niet gekoppeld aan het apparaat. Het apparaat verbindt met een algemeen channel genaamd
screens
, die een update verstuurt als er een scherm verbonden wordt. Dit is een algemeen bericht dat geldt voor alle schermen. - Het apparaat kan niet gevonden of de code kan niet gedecrypt worden. In dit geval moet de access token en device code weggegooid worden en het apparaat teruggaan naar de connect flow.
De autorisatie flow, in het diagram aangegeven met rood, wordt onder andere tijdens dit proces doorlopen. Het kan namelijk zijn dat de access token verlopen is. Als er een refresh token beschikbaar is, moet deze ingewisseld worden voor een nieuwe access en refresh token. Dit proces heeft een succesflow, waar het proces afgerond wordt en terug de huidige flow ingaat, en een errorflow, waar de refresh token niet ingewisseld kan worden en het apparaat teruggaat naar de connect flow. Deze autorisatie flow wordt op meerdere plekken gebruikt door de app flow, waarbij het input request een verschillende callback heeft, vandaar dat deze twee keer in het diagram staat. Een concreter voorbeeld van de autorisatie flow staat beschreven in de paragraaf Autorisatie.
Als dit alles slaagt, worden aan de hand van het scherm ID de events en views opgehaald. Dit gebeurt eenmalig na het identificeren van het apparaat, zodra de websocket aangeeft dat het scherm bijgewerkt is en als het scherm terug online komt, nadat de verbinding verloren was. Voor het ophalen van deze gegevens loopt het apparaat ook door de autorisatie flow. Mocht het ophalen van de gegevens niet lukken, dan moet het scherm dit opnieuw proberen totdat het wel lukt. De websocket stuurt namelijk eenmalig een bericht, dus zonder herhaling.
Als het ophalen van de events en views slaagt, worden deze doorgegeven aan het SolarCast component van de NPM package Screen Content. Deze gaat vervolgens zelfstandig aan de hand van het schema de juiste views afwisselen. Als er nul of één view momenteel actief is, wordt er enkel een timer gezet voor de start van het volgende event. Als er meerdere views actief zijn, worden deze met een extra timer afgewisseld. De timer die het eerst afloopt, wisselt dan de views.
Wellicht dat deze flows makkelijker te begrijpen zijn met de code ernaast. Zie onderstaande lijst:
- Main process (geel)
- Renderer Providers (oranje)
- Renderer Connect (paars)
- Renderer Fetcher (blauw)
- Renderer Autorisatie (rood)
- Renderer SolarCast (groen)
Flow
Alle code van de applicatie staat in de src
map, hier staat het startpunt van de code: index.ts. Dit index-bestand is onderdeel van Electron en roept modules uit de Electron
map aan. Als de applicatie geïnitialiseerd is, wordt index.html
aangeroepen die de renderer.ts
laadt. React rendert pagina's uit de Pages
map, die op hun beurt herbruikbare componenten aanroepen uit de Components
map. Daarnaast maakt de React gebruik van Models
en Services
. De models zijn classes die ontvangen of teruggestuurd worden naar de server, soms gedefinieerd als class en soms als type of interface van TypeScript. De services zijn functionaliteiten die op meerdere plekken in de code gebruikt worden zonder React componenten.
De code wordt getest met unittests en componenttests uit de src
map. Deze hebben een .test
of .spec
suffix. De end-2-end tests staan in de tests
map en hebben een .test
suffix. Deze zijn gericht vanuit de gebruiker en beschrijven een functionaliteit.
Onderstaande paragrafen beschrijven een abstracte sequence. Vrijwel alle dependencies (wit) worden overgeslagen en vaak wordt er verwezen naar mappen met soortgelijke bestanden (lichtgroen) in plaats van de specifieke bestanden zelf (donkergroen).
Electron
Onderstaande sequence beschrijft het relatief kleine deel van de applicatie dat Electron nodig heeft en hoe deze de React onderdelen aanroept. De renderer wordt omgezet naar een gecompileerd JavaScript bestand waarin de React modules staan. Deze wordt ingeladen in de HTML net zoals bij een reguliere webpagina.
Zie publiceren voor de Electron sequence van het bijwerken van de applicatie.
React
Onderstaand diagram beschrijft hoe de onderdelen binnen React zich tot elkaar verhouden. De renderer roept de index pagina op, omdat de renderer geen .tsx
extensie mag hebben. Vanuit het index-bestand wordt de App aangeroepen die op zijn beurt weer andere pagina's aanroept (zonder daadwerkelijk te navigeren). De app roept ook andere componenten aan die Models en Services gebruiken.
Alles dat mogelijk buiten deze applicatie gebruikt moet worden, staat in de Package map. Het is extreem belangrijk dat alle geïmporteerde bestanden toegevoegd zijn als (peer)dependency, anders werken deze mogelijk niet buiten deze repository. Als dit niet mogelijk is, bijvoorbeeld in het geval van de Electron webviews, dan moet het gebruik van deze objecten altijd optioneel zijn of een fallback hebben.
Testen
Jest test alle bestanden met de suffix .spec
of .test
. In dit project wordt de __tests__
map niet gebruikt. De tests van de componenten en de unittests staan bij de code en hebben dezelfde naam met suffix als het bestand dat getest wordt.
De end-2-end tests die gedaan worden met Spectron staan in de tests
map, omdat deze niet de naam van de component die getest wordt, kunnen hebben. Deze tests testen de functionaliteit gezien vanuit de gebruiker.
Bijwerken
Het compileren, bouwen en publiceren op GitHub gaat allemaal via Electron Forge. Na het toevoegen van een release ziet de updater dit en wordt de update gedownload en geïnstalleerd.
De gecompileerde code wordt eerst in de .webpack
map gezet. Hieruit wordt vervolgens een stand-alone build gemaakt in de out
map. GitHub Actions doet dit automatisch wanneer er een release gemaakt wordt met deze workflow. De bestanden worden dan aan de release gekoppeld als de versie met package.json
overeenkomt.
Normaal gesproken kan een Electron applicatie de updates direct van GitHub halen via de officiële Electron server. In dit geval kan dat niet, omdat de code in een private repository staat. Nuts wordt zelf gehost op Heruku en emuleert de endpoints van de Electron update server. De credentials worden aan de hand van een personal access token van GitHub door Nuts beheerd.
Er wordt gebruik gemaakt van een webhook om Nuts te laten weten dat er een nieuwe release beschikbaar is. In onderstaand diagram wordt de communicatie tussen GitHub en Nuts versimpeld om het diagram overzichtelijk te houden. Zie de officiële documentatie van Nuts voor de complete sequence.
Autorisatie
De autorisatie van de applicatie gaat via OAuth 2.0 Device Code Grant, zie het onderzoek voor meer informatie hierover. De flow is relatief simpel. In de app wordt gecontroleerd of er een access token en device code beschikbaar is. Als één van de twee mist, wordt de Connect pagina weergegeven, zodat de gebruiker de QR code kan scannen om het apparaat te verbinden.
Als beide beschikbaar zijn, gaat de app de vereiste gegevens ophalen. In het voorbeeld zijn dit de events en views, maar dit kunnen ook andere gegevens zijn, zie de autorisatie flow in continuïteit. Dit kan de eerste poging slagen wanneer de access token geldig is, maar kan ook falen wanneer de access token verlopen is. Als er een refresh token beschikbaar is, wordt deze ingeruild voor een nieuwe access token en wordt het request voor gegevens opnieuw uitgevoerd.
Het kan ook zo zijn dat de access token niet meer vernieuwd kan worden, dan wordt deze weggegooid en laat het scherm opnieuw de Connect pagina zien.
De Connect pagina staat in onderstaand diagram beschreven als black box, de sequence van dit component abstract beschreven in de paragraaf Flow en concreet beschreven in het onderzoek.
C4 Code
React tree
De React code is functioneel geprogrammeerd. De meeste componenten zijn een FunctionComponent
en doordat het in TypeScript geschreven is, staan alle props van het component altijd in hetzelfde bestand erboven. Vandaar dat deze documentatie geen standaard UML weergaves heeft. In plaats daarvan is gekozen om een abstractere boom weergave. Op deze manier wordt het toch inzichtelijk welke componenten waar gebruikt worden, zonder dat het een chaos wordt door de overvloed aan props die toch bij de componenten zelf in de code gedefinieerd staan. In alle bomen worden DOM elementen genegeerd, evenals de children van dependencies.
Een korte legenda voor alle diagrammen:
- Een component is donkergroen.
- Een component uit de package Screen Content is lichtgroen.
- Als een component zonder children vaker voorkomt, dan wordt deze slechts één keer weergegeven. Dit gebeurt bijvoorbeeld in een menu waar meerdere icoontjes in staan.
Merk op dat de Connect pagina geen onderdeel is van SolarCast, maar als child component aan SolarCast meegegeven wordt, zodat dit component eventueel later onderdeel kan worden van een pagina transitie. Pagina transities zijn nu nog niet geïmplementeerd.
Gevaren
Het is algemeen bekend dat Electron niet extreem stabiel is. Vandaar dat het extra belangrijk is om te zorgen dat de code kwaliteit hoog is, zodat er niets mis kan gaan. Kleine foutjes die normaal gesproken in de browser geen invloed hebben, kunnen een Electron applicatie laten crashen. Het grootste probleem daarvan is niet het crashen zelf, maar het feit dat de applicatie zichzelf daarna niet opnieuw opstart.
In de paragraaf continuïteit werden enkele gevaren van recursie in de applicatie al kort benoemd. Deze paragraaf bevat de opgedane ervaringen waarmee rekening gehouden moet worden tijdens het uitbreiden van de applicatie. Deze maatregelen voorkomen dat er memory leaks ontstaan wat uiteindelijk leidt tot een fatal out of memory issue.
Een timer moet altijd opgeslagen worden als variabele, ook wanneer deze eenmalig gebruikt wordt. Voordat de timer dan ingesteld wordt, kan de mogelijke vorige timer, die als het goed is dezelfde werking heeft, geannuleerd worden. Belangrijk om te weten is dat een timer binnen een React FunctionComponent altijd in een useRef hook opgeslagen moeten worden, omdat de variabelen binnen de scope van de FunctionComponent niet bewaard worden na het rerenderen. Dit alles geldt ook voor de
setInterval
functie.const ExampleComponent: VoidFunctionComponent = () => { + const timeoutRef = useRef<NodeJS.Timeout | null>(null); + + timeoutRef.current && clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { /** do something */ }, 1000); - setTimeout(() => { /** do something */ }, 1000); };
Een event listener buiten het FunctionComponent zelf mag nooit in de hoofdfunctie van de FunctionComponent toegevoegd worden, omdat deze dan na elke keer rerenderen opnieuw aangemaakt wordt. Voeg deze in plaats daarvan toe na zodra de component mount en verwijder deze vlak voordat de component unmount.
const ExampleComponent: VoidFunctionComponent = () => { + useEffect(() => { + const myFunction = () => { + // Do something + }; + + window.addEventListener('online', myFunction); + + return () => { + window.removeEventListener('online', myFunction); + }; + }, []); - window.addEventListener('online', () => { - // Do something - }); };
Een asyncroon proces, zoals het ophalen van gegevens via een API of het zetten van een timer, moet geannuleerd worden voordat een component unmount. Dit voorkomt dat het component niet opgeruimd wordt door de garbage collector, omdat deze nog referenties heeft in het asyncrone proces dat nog loopt. Voor meer informatie, zie dit artikel.
Gebruik maken van de remote van Electron leek in het verleden problemen op te leveren. De remote maakt het programmeren een stuk eenvoudiger, omdat je vanuit de renderer ook het main proces kunt aansturen. Onder andere door de instabiliteit en het feit dat de remote beveiligingsproblemen oplevert, is deze deprecated en kan beter niet meer gebruikt worden. Voor alle communicatie tussen de renderer en main proces moet nu de
ipcRenderer
en deipcMain
gebruikt worden.// Renderer process + ipcRenderer.send('get-env'); + ipcRenderer.once('set-env', (_event, arg) => { + doSomethingWithEnv(arg); + }); - doSomethingWithEnv( - remote.process.env - );
// Main proces + ipcMain.on('get-env', (event) => { + event.reply('set-env', process.env); + });
Limiteer het aantal webviews dat tegelijkertijd gebruikt wordt. Een webview is een vervanger voor een iFrame. Het is zwaarder om te draaien, maar biedt meer functionaliteiten en scheidt de twee processen, zodat de frame de content van de renderer niet meer kan manipuleren.
Het bijwerken van de applicatie lijkt ook één van de mogelijke redenen te zijn waardoor er out of memory errors ontstaan. Het is nog onbekend welk deel van de code dit veroorzaakt, maar waarschijnlijk heeft het ermee te maken dat de applicatie niet kan herstarten als er twee instanties actief zijn. Probeer daarom het bijwerken van de applicatie te limiteren en om Sentry in de gaten te houden na het bijwerken, deze verzamelt tegenwoordig ook alle fouten die optreden van de Squirrel module.