Simplify electron autoupdater#1361
Conversation
|
|
||
| (Get-Content $yamlPath) ` | ||
| | ForEach-Object { $_ -replace '^(\s*sha512:\s*).+', "`$1$base64" } ` | ||
| | ForEach-Object { $_ -replace '^(\s*sha512:\s*).+', ('${1}' + $base64) } ` |
There was a problem hiding this comment.
The build release script was sometimes generating wrongly the latest.yml because of the hash.
| logger.debug({ msg: 'New release available', latest, filePath }); | ||
|
|
||
| const installing = await checkExistingFile({ latest, filePath }); | ||
| if (installing) return true; |
There was a problem hiding this comment.
We want to check if the release was already downloaded to install it so we don't need to download it again, we just install it and open the app again.
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); |
There was a problem hiding this comment.
These are already removed with clearMocks: true in the vitest config.
There was a problem hiding this comment.
Hmm, I think clearMocks: true calls vi.clearAllMocks() before each test. But vi.clearAllMocks() is not calling vi.useRealTimers, so timers won't be reset cause they have a separate API
Can we rollback to |
|
Adding @TamaraFinogina here to ensure there are no security regression issues here regarding authenticity, filesystem usage and security guarantees in general @dajimenezriv-internxt (against the current |
|
@sg-gs Regarding the first comment. In case the download is corrupted we are checking against the |
| const res = await fetch('https://api.github.com/repos/internxt/drive-desktop/releases/latest'); | ||
| const data = await res.json(); | ||
| const release = data as { tag_name: string }; | ||
| const latest = release.tag_name.replace(/^v/, ''); |
There was a problem hiding this comment.
Shall we add a check that the tag doesn't have '../' or some weird characters? I think we have a fixed name for all tags, but even just checking for that tag only has letters/numbers should do
if (!/^[0-9A-Za-z._-]+$/.test(latest)) {
throw new Error("Wrong tag format");
}
There was a problem hiding this comment.
Actually, it's always with numbers v.X.Y.Z, so we can remove letters from the check
|
About a comment made by @TamaraFinogina, she asked me why don't use updateElectronApp({
updateSource: {
type: UpdateSourceType.ElectronPublicUpdateService,
repo: 'internxt/drive-desktop',
},
updateInterval: '5m',
logger: {
error(message) {
logger.debug({ msg: message });
},
warn(message) {
logger.debug({ msg: message });
},
info(message) {
logger.debug({ msg: message });
},
log(message) {
logger.debug({ msg: message });
},
},
});but it gave me some issues:
Btw, |
| !(startsWith(github.head_ref, 'merge-') && endsWith(github.head_ref, '-release') && github.base_ref == 'main') | ||
| # if: | | ||
| # !(startsWith(github.head_ref, 'merge-') && endsWith(github.head_ref, '-release') && github.base_ref == 'main') | ||
| if: false |
There was a problem hiding this comment.
Is the PR size checker turned off for all?
There was a problem hiding this comment.
Yes, because there are many tests and the lines were 550, to not split a test into another PR. It will be reverted in the next PR.
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); |
There was a problem hiding this comment.
Hmm, I think clearMocks: true calls vi.clearAllMocks() before each test. But vi.clearAllMocks() is not calling vi.useRealTimers, so timers won't be reset cause they have a separate API
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| it('should not schedule backups if there is no last backup', () => { |
There was a problem hiding this comment.
same, I don't think timers are affected by clearMocks
|
|
||
| try { | ||
| logger.debug({ msg: 'Release already downloaded' }); | ||
| await verifyHash({ filePath, latest }); |
There was a problem hiding this comment.
No version downgrade prevention?
There was a problem hiding this comment.
| calls(verifyHashMock).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('should install release if file exists and hash is valid', async () => { |
There was a problem hiding this comment.
Can we add a test for trying to install an inferior version to what the user already has? Say the user has 11.0.0 and we try to install 10.0.0
There was a problem hiding this comment.
It cannot reach this code, as explained in the above comment.
| try { | ||
| const res = await fetch('https://api.github.com/repos/internxt/drive-desktop/releases/latest'); | ||
| const data = await res.json(); | ||
| const { tag_name } = ReleaseSchema.parse(data); |
|
|
||
| if (!isNewer(INTERNXT_VERSION, latest)) { | ||
| logger.debug({ msg: 'App is up to date', latest }); | ||
| setTimeout(checkForUpdates, 60 * 60 * 1000); |
There was a problem hiding this comment.
existsSync (called inside checkForUpdates) will pose the app. Are we sure we want to do the periodic checks?
Link: https://www.geeksforgeeks.org/node-js/node-js-fs-existssync-method/
There was a problem hiding this comment.
In other words, I don't know how the file upload/download in progress will react to this interruption. @dajimenezriv-internxt will it be ok?
There was a problem hiding this comment.
Technically it's ok, also because it's something that we just do every hour, however I've been investigating more and probably we should access instead. I thought that it wasn't an async version because the existsSync was so fast than the overhead of making it async was not worth. It happens that with better_sqlite3 for example. I'm going to change it here and I will do the same change in the loop that iterates the whole file explorer in the next PR since there it will be more useful.
| import { installRelease } from './show-dialog'; | ||
| import { verifyHash } from './verify-hash'; | ||
|
|
||
| export async function checkExistingFile({ filePath, latest }: { filePath: string; latest: string }) { |
There was a problem hiding this comment.
technically, it's check and install
| icon: nativeImage.createFromPath(iconPath), | ||
| title: 'Update Available', | ||
| message: `Version ${latest} is available`, | ||
| detail: 'Download and install the update now?', |
There was a problem hiding this comment.
At this point, it's already downloaded
|
|
||
| logger.debug({ msg: 'Verifying release hash', actual }); | ||
|
|
||
| const url = `https://github.com/internxt/drive-desktop/releases/download/v${latest}/latest.yml`; |
There was a problem hiding this comment.
I think if we use api.github.com, we can get the latest without giving the release number
https://api.github.com/repos/internxt/drive-desktop/releases/latest
There was a problem hiding this comment.
True, it also applies to the .exe, I'm changing it.
|
|
||
| logger.debug({ msg: 'Verifying release hash', actual }); | ||
|
|
||
| const url = `https://github.com/internxt/drive-desktop/releases/download/v${latest}/latest.yml`; |
There was a problem hiding this comment.
https://github.com/internxt/drive-desktop/releases/latest/download/latest.yml
Wait, I think this one will work without a version number.
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); |
There was a problem hiding this comment.
same about timers, don't think clearMocks affects vi.useFakeTimers();
| }); | ||
|
|
||
| afterAll(() => { | ||
| vi.useRealTimers(); |
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.clearAllTimers(); |
| 'sonarjs/no-alphabetical-sort': 'off', | ||
| 'sonarjs/no-empty-test-file': 'off', | ||
| 'sonarjs/os-command': 'off', | ||
| 'sonarjs/publicly-writable-directories': 'off', |
There was a problem hiding this comment.
@dajimenezriv-internxt It's not fixing the Sonar warnings; it just disables them. Sonar keeps complaining but silently :)
There was a problem hiding this comment.
It's because we are using the test path /tmp inside the tests, however the rule is only disabled inside the tests. The warnings inside the production code are not disabled.
|
|
||
| const time = await measurePerfomance(async () => { | ||
| const url = `https://github.com/internxt/drive-desktop/releases/download/v${latest}/${fileName}`; | ||
| const res = await fetch(url); |
There was a problem hiding this comment.
@dajimenezriv-internxt Can you try this?
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
headers: { 'Accept': 'application/vnd.github+json' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const release = await res.json();
if (release.draft || release.prerelease) throw new Error('Latest release is not a stable release');
const asset = release.assets.find((a: { name: string }) =>
/^Internxt-Setup-[\d.]+\.exe$/.test(a.name)
);
if (!asset) throw new Error(`No setup .exe found in release ${release.tag_name}`);
const response = await fetch(asset.browser_download_url);
There was a problem hiding this comment.
What's the benefit? Because the filename is constructed from the latest.yml? The same as the browser_download_url. So technically if an attacker achieves to inject a different filename he should be able to inject the asset.browser_download_url` too, right?
There was a problem hiding this comment.
What we can do is to name all .exe as Internxt-Setup.exe and we have a fixed name and it doesn't change per release. However, I think that most open source projects include the version in the name as a standard.
| 'sonarjs/no-alphabetical-sort': 'off', | ||
| 'sonarjs/no-empty-test-file': 'off', | ||
| 'sonarjs/os-command': 'off', | ||
| 'sonarjs/publicly-writable-directories': 'off', |
There was a problem hiding this comment.
@dajimenezriv-internxt We should return it, it's a useful check.
We can always pass the latest as input and just do the path at the moment.
const filePath = join(tmpdir(), `Internxt-Setup-${latest}.exe`);
There was a problem hiding this comment.
The eslint rule doesn't apply there, it's just for the test files (https://github.com/internxt/drive-desktop/pull/1361/changes#diff-e2954b558f2aa82baff0e30964490d12942e0e251c1aa56c3294de6ec67b7cf5L21) because I'm writing /tmp inside a test: https://github.com/internxt/drive-desktop/pull/1361/changes#diff-ab5bba20517d999790e6182db1ccbb8a9d39f9dad2951ddcb9b2df62087e7457R13.
There was a problem hiding this comment.
Never mind, you are right, it's for tests only
|
|
||
| logger.debug({ msg: 'New release available', latest, filePath }); | ||
|
|
||
| const installing = await checkAndInstall({ filePath }); |
There was a problem hiding this comment.
Ideally, I would bring hash and installation on top, something like:
const alreadyDownloaded = await checkIfAlreadyDownloaded({ filePath });
if(!alreadyDownloaded) downloadRelease({fileName});
await verifyHash({ filePath });
const installNow = await showDialog({ latest });
if(installNow) installRelease({ filePath });
Now installation happens in two different points: with user confirmation (immediately after download) and without (when already downloaded). I guess it's on purpose.
Anyway, if we ever need to debug a client issue with updates, we will need to know in which path it happened, so the support will have to go back and forth asking for details (did the user press later or install, etc).
There was a problem hiding this comment.
- We already know the path it took because of the logs. I'm going to add a log for the dialog response.
- With the code you have provided we are not forcing the installation in the startup, you always show the dialog. I want to force the installation when the user opens the app.
There was a problem hiding this comment.
If the user selects 'later', in an hour there will be another check, and it will be installed, right?
There was a problem hiding this comment.
Nop, the timeout only runs if there wasn't any update, so we check again every hour. If the user clicks on Later we don't check again. See that the setTimeout only runs when the version is up to date. It will be installed after the user closes the app and opens it again (when turning off and on the laptop for example).
There was a problem hiding this comment.
const alreadyDownloaded = await checkIfAlreadyDownloaded({ filePath });
if (!alreadyDownloaded) downloadRelease({ fileName });
await verifyHash({ filePath });
const installNow = alreadyDownloaded ? true: await showDialog({ latest });
if (installNow) installRelease({ filePath });
Btw, this code will do what you need, but it's ok
There was a problem hiding this comment.
It won't, because downloadRelease needs to be awaited and that will block the main thread, that's why we need to write twice the verifyHash and so. We want to check if the release is downloaded and install before doing anything. Otherwise, we want to download and so in background.
|
|
If you can do a final review with the changes @sg-gs |




What
Previously we were using the package
electron-updaterwith uses theautoUpdatefeature of electron. However, during an issue that sometimes we were having with thelatest.ymland the hash I started investigating how the autoupdate was working by letting it work in my laptop. However, it was producing strange behaviours like this image when updating:I was reading about how it worked, but the customization was very limited and it was working as a black box. I started lookign for alternatives and found a repo that was doing something similar without the hash verification. So, since the implementation it's "simple" and we just need to support windows, I decided to implement it manually so we can customize it as we want and decide when to install, when to ask the user, when to force the update and so.
I've added in this comment 2 videos showing how the update will be made and the dialog that we show the user. https://inxt.atlassian.net/browse/PB-6340?focusedCommentId=71094