Repository: FreeTubeApp/FreeTube Branch: development Commit: e7d2685692c9 Files: 446 Total size: 4.9 MB Directory structure: gitextract_w3ol9anw/ ├── .babelrc ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ ├── config.yml │ │ └── feature_request.yaml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── auto-merge.yml │ ├── dependabot.yml │ ├── issue-labeler.yml │ ├── pr-labeler.yml │ └── workflows/ │ ├── autoMerge.yml │ ├── build.yml │ ├── calibreapp-image-actions.yml │ ├── codeql.yml │ ├── conflicts.yml │ ├── flatpak.yml │ ├── label-issue.yml │ ├── label-pr.yml │ ├── linter.yml │ ├── no-response.yml │ ├── release.yml │ ├── remove-outdated-labels.yml │ ├── stale.yml │ └── updateSite.yml ├── .gitignore ├── .stylelintignore ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── .whitesource ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _icons/ │ └── iconMac.icns ├── _scripts/ │ ├── ProcessLocalesPlugin.js │ ├── _undefinedDefaultExport.mjs │ ├── build.mjs │ ├── clean.mjs │ ├── dev-runner.js │ ├── ebuilder.config.mjs │ ├── eslint-rules/ │ │ ├── plugin.mjs │ │ └── prefer-use-i18n-polyfill-rule.mjs │ ├── findMissingTemplates.mjs │ ├── getInstances.js │ ├── getRegions.mjs │ ├── getShakaLocales.js │ ├── injectAllowedPaths.mjs │ ├── mime-db-shrinking-loader.js │ ├── patch-shaka-player-loader.js │ ├── sigFrameConfig.js │ ├── webpack.botGuardScript.config.js │ ├── webpack.main.config.js │ ├── webpack.preload.config.js │ ├── webpack.renderer.config.js │ └── webpack.web.config.js ├── eslint.config.mjs ├── jsconfig.json ├── lefthook-local.yml.example ├── lefthook.yml ├── package.json ├── src/ │ ├── botGuardScript.js │ ├── constants.js │ ├── data/ │ │ └── .gitkeep │ ├── datastores/ │ │ ├── handlers/ │ │ │ ├── base.js │ │ │ ├── electron.js │ │ │ ├── index.js │ │ │ └── web.js │ │ └── index.js │ ├── index.ejs │ ├── main/ │ │ ├── ImageCache.js │ │ ├── externalPlayer.js │ │ ├── index.js │ │ ├── poTokenGenerator.js │ │ └── utils.js │ ├── preload/ │ │ ├── interface.js │ │ ├── main.js │ │ └── preload-interface.d.ts │ └── renderer/ │ ├── App.css │ ├── App.vue │ ├── components/ │ │ ├── ChannelAbout/ │ │ │ ├── ChannelAbout.css │ │ │ └── ChannelAbout.vue │ │ ├── ChannelDetails/ │ │ │ ├── ChannelDetails.css │ │ │ └── ChannelDetails.vue │ │ ├── ChannelHome/ │ │ │ ├── ChannelHome.css │ │ │ └── ChannelHome.vue │ │ ├── CommentSection/ │ │ │ ├── CommentSection.css │ │ │ └── CommentSection.vue │ │ ├── DataSettings/ │ │ │ ├── DataSettings.css │ │ │ └── DataSettings.vue │ │ ├── DistractionSettings/ │ │ │ ├── DistractionSettings.css │ │ │ └── DistractionSettings.vue │ │ ├── ExperimentalSettings/ │ │ │ ├── ExperimentalSettings.css │ │ │ └── ExperimentalSettings.vue │ │ ├── ExternalPlayerSettings.vue │ │ ├── FtAgeRestricted/ │ │ │ ├── FtAgeRestricted.css │ │ │ └── FtAgeRestricted.vue │ │ ├── FtAutoGrid/ │ │ │ ├── FtAutoGrid.css │ │ │ └── FtAutoGrid.vue │ │ ├── FtAutoLoadNextPageWrapper.vue │ │ ├── FtButton/ │ │ │ ├── FtButton.css │ │ │ └── FtButton.vue │ │ ├── FtChannelBubble/ │ │ │ ├── FtChannelBubble.css │ │ │ └── FtChannelBubble.vue │ │ ├── FtCheckboxList/ │ │ │ ├── FtCheckboxList.css │ │ │ └── FtCheckboxList.vue │ │ ├── FtCommunityPoll/ │ │ │ ├── FtCommunityPoll.css │ │ │ └── FtCommunityPoll.vue │ │ ├── FtCommunityPost/ │ │ │ ├── FtCommunityPost.scss │ │ │ └── FtCommunityPost.vue │ │ ├── FtCreatePlaylistPrompt/ │ │ │ ├── FtCreatePlaylistPrompt.css │ │ │ └── FtCreatePlaylistPrompt.vue │ │ ├── FtElementList/ │ │ │ ├── FtElementList.css │ │ │ └── FtElementList.vue │ │ ├── FtIconButton/ │ │ │ ├── FtIconButton.scss │ │ │ └── FtIconButton.vue │ │ ├── FtInput/ │ │ │ ├── FtInput.css │ │ │ └── FtInput.vue │ │ ├── FtInputTags/ │ │ │ ├── FtInputTags.css │ │ │ └── FtInputTags.vue │ │ ├── FtKeyboardShortcutPrompt/ │ │ │ ├── FtKeyboardShortcutPrompt.css │ │ │ └── FtKeyboardShortcutPrompt.vue │ │ ├── FtListChannel/ │ │ │ ├── FtListChannel.scss │ │ │ └── FtListChannel.vue │ │ ├── FtListHashtag/ │ │ │ ├── FtListHashtag.scss │ │ │ └── FtListHashtag.vue │ │ ├── FtListLazyWrapper/ │ │ │ ├── FtListLazyWrapper.css │ │ │ └── FtListLazyWrapper.vue │ │ ├── FtListPlaylist/ │ │ │ ├── FtListPlaylist.scss │ │ │ └── FtListPlaylist.vue │ │ ├── FtListVideoLazy.vue │ │ ├── FtListVideoNumbered/ │ │ │ ├── FtListVideoNumbered.css │ │ │ └── FtListVideoNumbered.vue │ │ ├── FtLoader/ │ │ │ ├── FtLoader.css │ │ │ └── FtLoader.vue │ │ ├── FtLogoFull/ │ │ │ ├── FtLogoFull.css │ │ │ └── FtLogoFull.vue │ │ ├── FtNotificationBanner/ │ │ │ ├── FtNotificationBanner.css │ │ │ └── FtNotificationBanner.vue │ │ ├── FtPlaylistAddVideoPrompt/ │ │ │ ├── FtPlaylistAddVideoPrompt.css │ │ │ └── FtPlaylistAddVideoPrompt.vue │ │ ├── FtPlaylistSelector/ │ │ │ ├── FtPlaylistSelector.scss │ │ │ └── FtPlaylistSelector.vue │ │ ├── FtProfileBubble/ │ │ │ ├── FtProfileBubble.css │ │ │ └── FtProfileBubble.vue │ │ ├── FtProfileChannelList/ │ │ │ ├── FtProfileChannelList.css │ │ │ └── FtProfileChannelList.vue │ │ ├── FtProfileEdit/ │ │ │ ├── FtProfileEdit.css │ │ │ └── FtProfileEdit.vue │ │ ├── FtProfileFilterChannelsList/ │ │ │ ├── FtProfileFilterChannelsList.css │ │ │ └── FtProfileFilterChannelsList.vue │ │ ├── FtProfileSelector/ │ │ │ ├── FtProfileSelector.css │ │ │ └── FtProfileSelector.vue │ │ ├── FtProgressBar/ │ │ │ ├── FtProgressBar.css │ │ │ └── FtProgressBar.vue │ │ ├── FtPrompt/ │ │ │ ├── FtPrompt.css │ │ │ └── FtPrompt.vue │ │ ├── FtRadioButton/ │ │ │ ├── FtRadioButton.css │ │ │ └── FtRadioButton.vue │ │ ├── FtRefreshWidget/ │ │ │ ├── FtRefreshWidget.scss │ │ │ └── FtRefreshWidget.vue │ │ ├── FtSearchFilters/ │ │ │ ├── FtSearchFilters.css │ │ │ └── FtSearchFilters.vue │ │ ├── FtSelect/ │ │ │ ├── FtSelect.css │ │ │ └── FtSelect.vue │ │ ├── FtSettingsMenu/ │ │ │ ├── FtSettingsMenu.css │ │ │ └── FtSettingsMenu.vue │ │ ├── FtSettingsSection/ │ │ │ ├── FtSettingsSection.scss │ │ │ └── FtSettingsSection.vue │ │ ├── FtShareButton/ │ │ │ ├── FtShareButton.css │ │ │ └── FtShareButton.vue │ │ ├── FtSlider/ │ │ │ ├── FtSlider.css │ │ │ └── FtSlider.vue │ │ ├── FtSponsorBlockCategory/ │ │ │ ├── FtSponsorBlockCategory.css │ │ │ └── FtSponsorBlockCategory.vue │ │ ├── FtSubscribeButton/ │ │ │ ├── FtSubscribeButton.css │ │ │ └── FtSubscribeButton.vue │ │ ├── FtTimestampCatcher.vue │ │ ├── FtToast/ │ │ │ ├── FtToast.css │ │ │ └── FtToast.vue │ │ ├── FtToggleSwitch/ │ │ │ ├── FtToggleSwitch.scss │ │ │ └── FtToggleSwitch.vue │ │ ├── FtTooltip/ │ │ │ ├── FtTooltip.css │ │ │ └── FtTooltip.vue │ │ ├── GeneralSettings/ │ │ │ ├── GeneralSettings.css │ │ │ └── GeneralSettings.vue │ │ ├── ParentalControlSettings.vue │ │ ├── PasswordDialog/ │ │ │ ├── PasswordDialog.css │ │ │ └── PasswordDialog.vue │ │ ├── PasswordSettings/ │ │ │ ├── PasswordSettings.css │ │ │ └── PasswordSettings.vue │ │ ├── PlayerSettings/ │ │ │ ├── PlayerSettings.css │ │ │ └── PlayerSettings.vue │ │ ├── PlaylistInfo/ │ │ │ ├── PlaylistInfo.scss │ │ │ └── PlaylistInfo.vue │ │ ├── PrivacySettings.vue │ │ ├── ProxySettings/ │ │ │ ├── ProxySettings.css │ │ │ └── ProxySettings.vue │ │ ├── SideNav/ │ │ │ ├── SideNav.css │ │ │ └── SideNav.vue │ │ ├── SideNavMoreOptions/ │ │ │ ├── SideNavMoreOptions.css │ │ │ └── SideNavMoreOptions.vue │ │ ├── SponsorBlockSettings.vue │ │ ├── SubscriptionSettings/ │ │ │ ├── SubscriptionSettings.css │ │ │ └── SubscriptionSettings.vue │ │ ├── SubscriptionsLive.vue │ │ ├── SubscriptionsPosts.vue │ │ ├── SubscriptionsShorts.vue │ │ ├── SubscriptionsTabUi/ │ │ │ ├── SubscriptionsTabUi.css │ │ │ └── SubscriptionsTabUi.vue │ │ ├── SubscriptionsVideos.vue │ │ ├── ThemeSettings.vue │ │ ├── TopNav/ │ │ │ ├── TopNav.scss │ │ │ └── TopNav.vue │ │ ├── WatchVideoChapters/ │ │ │ ├── WatchVideoChapters.css │ │ │ └── WatchVideoChapters.vue │ │ ├── WatchVideoDescription/ │ │ │ ├── WatchVideoDescription.css │ │ │ └── WatchVideoDescription.vue │ │ ├── WatchVideoInfo/ │ │ │ ├── WatchVideoInfo.css │ │ │ └── WatchVideoInfo.vue │ │ ├── WatchVideoLiveChat/ │ │ │ ├── WatchVideoLiveChat.css │ │ │ └── WatchVideoLiveChat.vue │ │ ├── WatchVideoRecommendations/ │ │ │ ├── WatchVideoRecommendations.css │ │ │ └── WatchVideoRecommendations.vue │ │ ├── ft-card/ │ │ │ ├── ft-card.css │ │ │ ├── ft-card.js │ │ │ └── ft-card.vue │ │ ├── ft-flex-box/ │ │ │ ├── ft-flex-box.css │ │ │ ├── ft-flex-box.js │ │ │ └── ft-flex-box.vue │ │ ├── ft-list-video/ │ │ │ ├── ft-list-video.js │ │ │ ├── ft-list-video.scss │ │ │ └── ft-list-video.vue │ │ ├── ft-shaka-video-player/ │ │ │ ├── ft-shaka-video-player.css │ │ │ ├── ft-shaka-video-player.js │ │ │ ├── ft-shaka-video-player.vue │ │ │ └── player-components/ │ │ │ ├── AudioTrackSelection.js │ │ │ ├── AutoplayToggle.js │ │ │ ├── FullWindowButton.js │ │ │ ├── LegacyQualitySelection.js │ │ │ ├── ScreenshotButton.js │ │ │ ├── SkipButton.js │ │ │ ├── StatsButton.js │ │ │ └── TheatreModeButton.js │ │ └── watch-video-playlist/ │ │ ├── watch-video-playlist.css │ │ ├── watch-video-playlist.js │ │ └── watch-video-playlist.vue │ ├── composables/ │ │ ├── colors.js │ │ └── use-i18n-polyfill.js │ ├── directives/ │ │ └── vSaferHtml.js │ ├── fontawesome-minimal.js │ ├── helpers/ │ │ ├── api/ │ │ │ ├── PlayerCache.js │ │ │ ├── invidious.js │ │ │ └── local.js │ │ ├── channels.js │ │ ├── colors.js │ │ ├── player/ │ │ │ ├── EbmlParser.js │ │ │ ├── Mp4SegmentIndexParser.js │ │ │ ├── SabrManifestParser.js │ │ │ ├── SabrSchemePlugin.js │ │ │ ├── WebmSegmentIndexParser.js │ │ │ └── utils.js │ │ ├── playlists.js │ │ ├── sponsorblock.js │ │ ├── strings.js │ │ ├── subscriptions.js │ │ └── utils.js │ ├── i18n/ │ │ └── index.js │ ├── main.js │ ├── router/ │ │ └── index.js │ ├── scss-partials/ │ │ ├── _ft-list-item.scss │ │ └── _utils.scss │ ├── sigFrameScript.js │ ├── store/ │ │ ├── index.js │ │ └── modules/ │ │ ├── history.js │ │ ├── invidious.js │ │ ├── player.js │ │ ├── playlists.js │ │ ├── profiles.js │ │ ├── search-history.js │ │ ├── settings.js │ │ ├── subscription-cache.js │ │ └── utils.js │ ├── themes.css │ └── views/ │ ├── About/ │ │ ├── About.css │ │ └── About.vue │ ├── Channel/ │ │ ├── Channel.css │ │ └── Channel.vue │ ├── Hashtag/ │ │ ├── Hashtag.css │ │ └── Hashtag.vue │ ├── History/ │ │ ├── History.css │ │ └── History.vue │ ├── Playlist/ │ │ ├── Playlist.scss │ │ └── Playlist.vue │ ├── Popular/ │ │ ├── Popular.css │ │ └── Popular.vue │ ├── Post.vue │ ├── ProfileSettings/ │ │ ├── ProfileSettings.css │ │ └── ProfileSettings.vue │ ├── SearchPage/ │ │ ├── SearchPage.css │ │ └── SearchPage.vue │ ├── Settings/ │ │ ├── Settings.css │ │ └── Settings.vue │ ├── SubscribedChannels/ │ │ ├── SubscribedChannels.css │ │ └── SubscribedChannels.vue │ ├── Subscriptions/ │ │ ├── Subscriptions.css │ │ └── Subscriptions.vue │ ├── Trending/ │ │ ├── Trending.css │ │ └── Trending.vue │ ├── UserPlaylists/ │ │ ├── UserPlaylists.css │ │ └── UserPlaylists.vue │ └── Watch/ │ ├── Watch.js │ ├── Watch.scss │ └── Watch.vue ├── static/ │ ├── .gitkeep │ ├── external-player-map.json │ ├── geolocations/ │ │ ├── ar.json │ │ ├── be.json │ │ ├── bg.json │ │ ├── ca.json │ │ ├── cs.json │ │ ├── da.json │ │ ├── de-DE.json │ │ ├── el.json │ │ ├── en-GB.json │ │ ├── en-US.json │ │ ├── es-AR.json │ │ ├── es-MX.json │ │ ├── es.json │ │ ├── et.json │ │ ├── eu.json │ │ ├── fa.json │ │ ├── fi.json │ │ ├── fr-FR.json │ │ ├── gl.json │ │ ├── he.json │ │ ├── hr.json │ │ ├── hu.json │ │ ├── id.json │ │ ├── is.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── lt.json │ │ ├── nb-NO.json │ │ ├── nl.json │ │ ├── nn.json │ │ ├── pl.json │ │ ├── pt-BR.json │ │ ├── pt-PT.json │ │ ├── pt.json │ │ ├── ro.json │ │ ├── ru.json │ │ ├── sk.json │ │ ├── sl.json │ │ ├── sr.json │ │ ├── sv.json │ │ ├── ta.json │ │ ├── tr.json │ │ ├── uk.json │ │ ├── vi.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── invidious-instances.json │ ├── locales/ │ │ ├── activeLocales.json │ │ ├── af.yaml │ │ ├── ar.yaml │ │ ├── as.yaml │ │ ├── awa.yaml │ │ ├── az.yaml │ │ ├── be.yaml │ │ ├── bg.yaml │ │ ├── bn.yaml │ │ ├── br.yaml │ │ ├── bs.yaml │ │ ├── ca.yaml │ │ ├── ckb.yaml │ │ ├── cs.yaml │ │ ├── cy.yaml │ │ ├── da.yaml │ │ ├── de-DE.yaml │ │ ├── el.yaml │ │ ├── en-GB.yaml │ │ ├── en-US.yaml │ │ ├── eo.yaml │ │ ├── es-AR.yaml │ │ ├── es-MX.yaml │ │ ├── es.yaml │ │ ├── et.yaml │ │ ├── eu.yaml │ │ ├── fa.yaml │ │ ├── fi.yaml │ │ ├── fil.yaml │ │ ├── fr-FR.yaml │ │ ├── gl.yaml │ │ ├── gsw.yaml │ │ ├── he.yaml │ │ ├── hi.yaml │ │ ├── hr.yaml │ │ ├── hu.yaml │ │ ├── id.yaml │ │ ├── is.yaml │ │ ├── it.yaml │ │ ├── ja.yaml │ │ ├── ka.yaml │ │ ├── km.yaml │ │ ├── ko.yaml │ │ ├── ku.yaml │ │ ├── la.yaml │ │ ├── lt.yaml │ │ ├── lv.yaml │ │ ├── ms.yaml │ │ ├── my.yaml │ │ ├── nb-NO.yaml │ │ ├── ne.yaml │ │ ├── nl.yaml │ │ ├── nn.yaml │ │ ├── or.yaml │ │ ├── pl.yaml │ │ ├── pt-BR.yaml │ │ ├── pt-PT.yaml │ │ ├── pt.yaml │ │ ├── ro.yaml │ │ ├── ru.yaml │ │ ├── sat.yaml │ │ ├── si.yaml │ │ ├── sk.yaml │ │ ├── sl.yaml │ │ ├── sm.yaml │ │ ├── sq.yaml │ │ ├── sr.yaml │ │ ├── sv.yaml │ │ ├── ta.yaml │ │ ├── ti.yaml │ │ ├── tok.yaml │ │ ├── tr.yaml │ │ ├── uk.yaml │ │ ├── ur.yaml │ │ ├── uz.yaml │ │ ├── vi.yaml │ │ ├── vls.yaml │ │ ├── xh.yaml │ │ ├── zh-CN.yaml │ │ └── zh-TW.yaml │ ├── manifest.json │ └── pwabuilder-sw.js └── stylelint.config.mjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ [ "@babel/env", { "targets": { "chrome": "130", "node": "20.9.0" } } ] ] } ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: Bug Report description: Report an issue or unexpected behavior that occurs within the application title: "[Bug]: " labels: ["bug"] body: - type: markdown attributes: value: | **README: Before You Submit Your Issue** - Issues are not a place to go ask support questions or start discussions. Please ask support questions or start discussions on the [discussions page](https://github.com/FreeTubeApp/FreeTube/discussions). - type: checkboxes attributes: label: Guidelines description: Please ensure you've completed all of the following. options: - label: I have encountered this bug in the [latest release of FreeTube](https://github.com/FreeTubeApp/FreeTube/releases). required: true - label: I have encountered this bug in the [official downloads of FreeTube](https://github.com/FreeTubeApp/FreeTube#official-downloads). required: true - label: I have [searched the issue tracker for open and closed issues](https://github.com/FreeTubeApp/FreeTube/issues?q=is%3Aissue+sort%3Arelevance-desc) that are similar to the bug report I want to file, without success. required: true - label: I have searched the [documentation](https://docs.freetubeapp.io/) for information that matches the description of the bug I want to file, without success. required: true - label: This issue contains only one bug. required: true - label: I have read and agree to follow the [rules](https://docs.freetubeapp.io/community/rules/). required: true - type: textarea attributes: label: Describe the bug description: A clear and concise description of what the bug is. placeholder: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea attributes: label: Expected Behavior description: A clear and concise description of what you expected to happen. validations: required: true - type: dropdown attributes: label: 'Issue Labels' description: Please select a label that fits this bug report. Choose multiple, if applicable. multiple: true options: - accessibility issue - API issue - causes crash - content not loading - data loss - feature stopped working - inconsistent behavior - keyboard control not working - only happens in developer mode - race condition - text/string issue - usability issue - visual bug validations: required: true - type: input attributes: label: FreeTube Version description: | If using releases, enter the version. If using nightly builds, enter commit hash or build number, you can find it via about page in the FreeTube application. placeholder: v0.14.0, 8c4278 validations: required: true - type: input attributes: label: Operating System Version description: What operating system version are you using? On Windows, click Start button > Settings > System > About. On macOS, click the Apple Menu > About This Mac. On Linux, use lsb_release or uname -a. placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04" validations: required: true - type: dropdown attributes: label: Installation Method description: When you select an unofficial installation method, you must have verified that the bug is also present in one of the official installation methods. Please make sure you uninstall the unofficial installation before installing one of the official installations. If you can't reproduce this in one of the official installation methods, you should report the bug to the maintainer of the unofficial installation method you used. options: - .AppImage - .deb - .dmg - .exe - Flathub - .pacman - Portable - .rpm - .zip / .7z - .apk (FreeTubeAndroid Unofficial) - .apk (Alpine Linux Package Unofficial) - AUR (Unofficial) - Chocolatey (Unofficial) - Homebrew (Unofficial) - Nix (Unofficial) - PortableApps (Unofficial) - Scoop (Unofficial) - Snapcraft (Unofficial) - WAPT (Unofficial) - winget (Unofficial) - other validations: required: true - type: dropdown attributes: label: Primary API used description: What is the primary API being used? multiple: false options: - Local API - Invidious API validations: required: true - type: input attributes: label: 'Last Known Working FreeTube Version (If Any)' description: What is the last version of FreeTube this worked in, if applicable? placeholder: v0.14.0 - type: upload id: screenshots attributes: label: Upload screenshots or videos description: If applicable, add screenshots or videos to help explain your problem. validations: required: false - type: textarea attributes: label: Additional Information description: | Add additional information here. You may drag-and-drop log files here, or paste the log file in code blocks. - type: checkboxes attributes: label: Nightly Build description: Please ensure you've completed the following, if applicable. options: - label: I have encountered this bug in the latest [nightly build](https://docs.freetubeapp.io/development/nightly-builds). required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Discussions url: https://github.com/FreeTubeApp/FreeTube/discussions/categories/general about: View discussions or start one yourself - name: Questions url: https://github.com/FreeTubeApp/FreeTube/discussions/categories/q-a about: Ask and answer questions - name: Matrix Community url: https://matrix.to/#/#freetube:matrix.org about: 'Join our Matrix chatroom - "Note: Bugs and Feature requests should be made on GitHub and not in the Matrix room"' - name: Translate FreeTube url: https://hosted.weblate.org/engage/free-tube/ about: Help translate FreeTube on Weblate - name: FreeTube Documentation url: https://docs.freetubeapp.io/ about: View the Documentation to find all relevant information about FreeTube ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ name: Feature request description: Suggest an idea for FreeTube which you would like to see in a future release title: "[Feature Request]: " labels: "enhancement" body: - type: markdown attributes: value: | **README: Before You Submit Your Issue** - Issues are not a place to go ask support questions or start discussions. Please ask support questions or start discussions on the [discussions page](https://github.com/FreeTubeApp/FreeTube/discussions). - type: checkboxes attributes: label: Guidelines description: Please ensure you've completed all of the following. options: - label: I have [searched the issue tracker for open and closed issues](https://github.com/FreeTubeApp/FreeTube/issues?q=is%3Aissue+sort%3Arelevance-desc) that are similar to the feature request I want to file, without success. required: true - label: I have searched the [documentation](https://docs.freetubeapp.io/) for information that matches the description of the feature request I want to file, without success. required: true - label: This issue contains only one feature request. required: true - label: I have read and agree to follow the [rules](https://docs.freetubeapp.io/community/rules/). required: true - type: textarea attributes: label: Problem Description description: Please add a clear and concise description of the problem you are seeking to solve with this feature request. validations: required: true - type: textarea attributes: label: Proposed Solution description: Describe the solution you'd like in a clear and concise manner. validations: required: true - type: textarea attributes: label: Alternatives Considered description: A clear and concise description of any alternative solutions or features you've considered. validations: required: true - type: dropdown attributes: label: 'Issue Labels' description: Please select a label that fits this feature request. Choose multiple, if applicable. multiple: true options: - display more information to user - ease of use improvement - improvement to existing feature - new feature - new keyboard shortcut - new optional setting - support for external software - visual improvement validations: required: true - type: input attributes: label: FreeTube Version description: | If using releases, enter the version. If using nightly builds, enter commit hash or build number, you can find it via about page in the FreeTube application. placeholder: v0.14.0, 8c4278 validations: required: true - type: input attributes: label: Operating System Version description: What operating system version are you using? On Windows, click Start button > Settings > System > About. On macOS, click the Apple Menu > About This Mac. On Linux, use lsb_release or uname -a. placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04" validations: required: true - type: dropdown attributes: label: Installation Method description: If you are using an unofficial installation method, please verify that the requested feature is not already available in one of the official installation methods. If the feature only relates to an unofficial installation, please direct the request to the maintainer of that installation method instead. options: - .AppImage - .deb - .dmg - .exe - Flathub - .pacman - Portable - .rpm - .zip / .7z - .apk (FreeTubeAndroid Unofficial) - .apk (Alpine Linux Package Unofficial) - AUR (Unofficial) - Chocolatey (Unofficial) - Homebrew (Unofficial) - Nix (Unofficial) - PortableApps (Unofficial) - Scoop (Unofficial) - Snapcraft (Unofficial) - WAPT (Unofficial) - winget (Unofficial) - other validations: required: true - type: upload id: screenshots attributes: label: Upload screenshots or videos description: If applicable, add screenshots or videos to help explain your request. validations: required: false - type: textarea attributes: label: Additional Information description: | Add any other context about the feature request here. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Pull Request Type - [ ] Bugfix - [ ] Feature Implementation - [ ] Documentation - [ ] Other ## Related issue ## Description ## Screenshots ## Testing ## Desktop - **OS:** - **OS Version:** - **FreeTube version:** ## Additional context ================================================ FILE: .github/auto-merge.yml ================================================ minApprovals: COLLABORATOR: 2 maxRequestedChanges: COLLABORATOR: 0 mergeMethod: squash requiredBaseBranches: - development - test reportStatus: true ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" labels: - "PR: waiting for review" - "PR: dependencies" open-pull-requests-limit: 15 groups: babel: patterns: - "@babel/*" - "babel-*" eslint: patterns: - "eslint" - "eslint-*" - "@eslint/*" - "vue-eslint-parser" - "neostandard" - "@intlify/eslint-plugin-vue-i18n" - "@stylistic/eslint-plugin" stylelint: patterns: - "stylelint" - "stylelint-*" - "postcss" - "postcss-*" - "@double-great/stylelint-a11y" fortawesome: patterns: - "@fortawesome/*" webpack: patterns: - "css-loader" - "mini-css-extract-plugin" - "sass" - "sass-loader" - "webpack" - "webpack-*" - "*-webpack-plugin" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" labels: - "PR: waiting for review" - "PR: dependencies" ================================================ FILE: .github/issue-labeler.yml ================================================ 'B: visual': - '(visual bug)' 'B: Unofficial Download': - '(AUR \(Unofficial\)|Chocolatey \(Unofficial\)|\.apk \(FreeTubeAndroid Unofficial\)|Homebrew \(Unofficial\)|PortableApps \(Unofficial\)|WAPT \(Unofficial\)|winget \(Unofficial\)|Scoop \(Unofficial\)|Snapcraft \(Unofficial\)|Nix \(Unofficial\))' 'B: keyboard control': - '(keyboard control not working)' 'B: text/string': - '(text/string issue)' 'B: content not loading': - '(content not loading)' 'B: accessibility': - '(accessibility issue)' 'B: usability': - '(usability issue)' 'B: crash': - '(causes crash)' 'B: feature stopped working': - '(feature stopped working)' 'B: inconsistent behavior': - '(inconsistent behavior)' 'B: data loss': - '(data loss)' 'B: race condition': - '(race condition)' 'B: API issue': - '(API issue)' 'B: developer mode': - '(only happens in developer mode)' 'E: improvement existing feature': - '(improvement to existing feature)' 'E: new optional setting': - '(new optional setting)' 'E: visual improvement': - '(visual improvement)' 'E: display more information': - '(display more information to user)' 'E: ease of use improvement': - '(ease of use improvement)' 'E: support external software': - '(support for external software)' 'E: new feature': - '(new feature)' 'E: keyboard shortcut': - '(new keyboard shortcut)' ================================================ FILE: .github/pr-labeler.yml ================================================ 'PR: waiting for review': - changed-files: - any-glob-to-any-file: '**' 'PR: dependencies': - any: - changed-files: - any-glob-to-any-file: ['yarn.lock', 'package.json'] ================================================ FILE: .github/workflows/autoMerge.yml ================================================ name: Auto Merge PR on: pull_request_target: types: [opened, reopened, auto_merge_disabled, ready_for_review] permissions: {} jobs: build: if: | github.event.pull_request.state == 'open' && !github.event.pull_request.draft && (contains(github.event.pull_request.base.ref, 'development') || contains(github.event.pull_request.base.ref, 'RC')) runs-on: ubuntu-slim permissions: contents: write steps: - name: Auto Merge PR shell: bash env: GH_TOKEN: ${{ secrets.PUSH_TOKEN }} PR_URL: ${{ github.event.pull_request.html_url }} run: gh pr merge "$PR_URL" --auto --squash ================================================ FILE: .github/workflows/build.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: Build on: push: branches: [ master, development, '**-RC' ] workflow_dispatch: permissions: {} jobs: build: # As this action runs on every push to the default branch and uses quite a lot of resources (both minutes and caches), # only run it in the FreeTubeApp/FreeTube repository to avoid unnecessary GitHub Actions usage/billing in forks. # Still allow manually triggering the workflow. # If a fork does need this workflow to run on every push to the default branch, they can change this condition in their fork to include their repository. if: github.repository == 'FreeTubeApp/FreeTube' || github.event_name == 'workflow_dispatch' permissions: actions: write contents: read strategy: matrix: node-version: [24.x] runtime: - linux-x64 - linux-armv7l - linux-arm64 - win-x64 - win-arm64 - osx-x64 - osx-arm64 include: - runtime: linux-x64 os: ubuntu-latest - runtime: linux-armv7l os: ubuntu-latest - runtime: linux-arm64 os: ubuntu-latest - runtime: osx-x64 os: macOS-latest - runtime: osx-arm64 os: macOS-latest - runtime: win-x64 os: windows-latest - runtime: win-arm64 os: windows-latest runs-on: ${{ matrix.os }} steps: - name: 'Use faster D: drive for yarn cache on Windows' if: startsWith(matrix.os, 'windows') shell: cmd run: yarn config set cache-folder D:\ft_yarn_cache - name: Get yarn cache directory id: cache_dir shell: bash run: | { echo 'cache_dir<> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: persist-credentials: false - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #v6.3.0 with: node-version: ${{ matrix.node-version }} package-manager-cache: false - name: Restore yarn cache id: restore_cache uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3 with: key: ${{ format('node-cache-{0}-{1}-yarn-{2}', runner.os, runner.arch, hashFiles('yarn.lock')) }} path: ${{ steps.cache_dir.outputs.cache_dir }} - run: yarn run ci shell: bash env: ELECTRON_SKIP_BINARY_DOWNLOAD: '1' - run: yarn run lint shell: bash - name: Set version number id: versionNumber shell: bash run: | original_version="$(jq -r '.version' package.json)" version="$original_version" lower_case_ref="$(echo -E "$GITHUB_REF" | tr '[:upper:]' '[:lower:]')" if [[ "$lower_case_ref" == *development* ]]; then version="${original_version}-nightly-${GITHUB_RUN_NUMBER}" elif [[ "$lower_case_ref" == *rc* ]]; then version="${original_version}-RC-${GITHUB_RUN_NUMBER}" fi package_json="$(jq --arg version "$version" '.version = $version' package.json)" echo -E "$package_json" > package.json echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Install libarchive-tools if: startsWith(matrix.os, 'ubuntu') shell: bash run: | sudo apt update sudo apt -y install libarchive-tools - name: Build x64 with Node.js ${{ matrix.node-version}} if: contains(matrix.runtime, 'x64') shell: bash run: yarn run build - name: Build ARMv7l with Node.js ${{ matrix.node-version}} if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') shell: bash run: yarn run build:arm32 - name: Build ARM64 with Node.js ${{ matrix.node-version}} if: contains(matrix.runtime, 'arm64') shell: bash run: yarn run build:arm64 - name: Convert X64 AppImage to static runtime if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') shell: bash env: VERSION: ${{ steps.versionNumber.outputs.version }} run: | sudo apt install desktop-file-utils cd build appimage="FreeTube-${VERSION}.AppImage" wget "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -O ./appimagetool.AppImage chmod +x ./"$appimage" ./appimagetool.AppImage ./"$appimage" --appimage-extract && rm -f ./"$appimage" ./appimagetool.AppImage --comp zstd --mksquashfs-opt -Xcompression-level --mksquashfs-opt 20 \ -n ./squashfs-root ./"$appimage" rm -rf ./squashfs-root ./appimagetool.AppImage - name: Upload Linux .zip x64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-linux-x64-portable.zip path: build/freetube-${{ steps.versionNumber.outputs.version }}.zip - name: Upload Linux .7z x64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-linux-x64-portable.7z path: build/freetube-${{ steps.versionNumber.outputs.version }}.7z - name: Upload Linux .zip ARMv7l Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube-${{ steps.versionNumber.outputs.version }}-linux-armv7l-portable.zip path: build/freetube-${{ steps.versionNumber.outputs.version }}-armv7l.zip - name: Upload Linux .7z ARMv7l Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube-${{ steps.versionNumber.outputs.version }}-linux-armv7l-portable.7z path: build/freetube-${{ steps.versionNumber.outputs.version }}-armv7l.7z - name: Upload Linux .zip ARM64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-linux-arm64-portable.zip path: build/freetube-${{ steps.versionNumber.outputs.version }}-arm64.zip - name: Upload Linux .7z ARM64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-linux-arm64-portable.7z path: build/freetube-${{ steps.versionNumber.outputs.version }}-arm64.7z - name: Upload .deb x64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.version }}_amd64.deb path: build/freetube_${{ steps.versionNumber.outputs.version }}_amd64.deb - name: Upload .deb ARMv7l Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.version }}_armv7l.deb path: build/freetube_${{ steps.versionNumber.outputs.version }}_armv7l.deb - name: Upload .deb ARM64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.version }}_arm64.deb path: build/freetube_${{ steps.versionNumber.outputs.version }}_arm64.deb - name: Upload AppImage x64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-amd64.AppImage path: build/FreeTube-${{ steps.versionNumber.outputs.version }}.AppImage - name: Upload AppImage ARMv7l Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube-${{ steps.versionNumber.outputs.version }}-armv7l.AppImage path: build/FreeTube-${{ steps.versionNumber.outputs.version }}-armv7l.AppImage - name: Upload AppImage ARM64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-arm64.AppImage path: build/FreeTube-${{ steps.versionNumber.outputs.version }}-arm64.AppImage - name: Upload .rpm x64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}.amd64.rpm path: build/freetube-${{ steps.versionNumber.outputs.version }}.x86_64.rpm # rpm are not built for armv7l - name: Upload .rpm ARM64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}.arm64.rpm path: build/freetube-${{ steps.versionNumber.outputs.version }}.aarch64.rpm - name: Upload Pacman .pacman x64 Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-amd64.pacman path: build/freetube-${{ steps.versionNumber.outputs.version }}.pacman - name: Upload Windows x64 .exe Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-setup-x64.exe path: build/freetube Setup ${{ steps.versionNumber.outputs.version }}.exe - name: Upload Windows x64 Portable Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-win-x64-portable.exe path: build/freetube ${{ steps.versionNumber.outputs.version }}.exe - name: Upload Windows x64 .zip Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-win-x64-portable.zip path: build/freetube-${{ steps.versionNumber.outputs.version }}-win.zip - name: Upload Windows x64 .7z Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-win-x64-portable.7z path: build/freetube-${{ steps.versionNumber.outputs.version }}-win.7z - name: Upload Windows arm64 .exe Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-setup-arm64.exe path: build/freetube Setup ${{ steps.versionNumber.outputs.version }}.exe - name: Upload Windows arm64 Portable Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-win-arm64-portable.exe path: build/freetube ${{ steps.versionNumber.outputs.version }}.exe - name: Upload Windows arm64 .zip Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-win-arm64-portable.zip path: build/freetube-${{ steps.versionNumber.outputs.version }}-arm64-win.zip - name: Upload Windows arm64 .7z Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-win-arm64-portable.7z path: build/freetube-${{ steps.versionNumber.outputs.version }}-arm64-win.7z - name: Upload Mac x64 .dmg Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-mac-x64.dmg path: build/freetube-${{ steps.versionNumber.outputs.version }}.dmg - name: Upload Mac x64 .zip Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-mac-x64.zip path: build/freetube-${{ steps.versionNumber.outputs.version }}-mac.zip - name: Upload Mac x64 .7z Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-mac-x64.7z path: build/freetube-${{ steps.versionNumber.outputs.version }}-mac.7z - name: Upload Mac arm64 .dmg Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-mac-arm64.dmg path: build/freetube-${{ steps.versionNumber.outputs.version }}-arm64.dmg - name: Upload Mac arm64 .zip Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-mac-arm64.zip path: build/freetube-${{ steps.versionNumber.outputs.version }}-arm64-mac.zip - name: Upload Mac arm64 .7z Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') with: name: freetube-${{ steps.versionNumber.outputs.version }}-mac-arm64.7z path: build/freetube-${{ steps.versionNumber.outputs.version }}-arm64-mac.7z - name: Save yarn cache uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3 # Only save the cache if we weren't able to restore an existing one above if: steps.restore_cache.outputs.cache-primary-key != steps.restore_cache.outputs.cache-matched-key with: key: ${{ steps.restore_cache.outputs.cache-primary-key }} path: ${{ steps.cache_dir.outputs.cache_dir }} ================================================ FILE: .github/workflows/calibreapp-image-actions.yml ================================================ # Compress images on demand (workflow_dispatch), and at 12am every Sunday (schedule). # Open a Pull Request if any images can be compressed. name: Compress Images on: workflow_dispatch: schedule: - cron: '0 0 * * 0' permissions: {} jobs: build: # As this action runs on a schedule, only run it in the FreeTubeApp/FreeTube repository to avoid unnecessary GitHub Actions usage/billing in forks. # Still allow the workflow to be manually triggered. # If a fork does need this workflow, they can change this condition in their fork to include their repository. if: github.repository == 'FreeTubeApp/FreeTube' || github.event_name == 'workflow_dispatch' name: calibreapp/image-actions runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - name: Checkout Repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: persist-credentials: false - name: Compress Images id: calibre uses: calibreapp/image-actions@d9c8ee5c3dc52ae4622c82ead88d658f4b16b65f with: githubToken: ${{ secrets.GITHUB_TOKEN }} compressOnly: true - name: Create New Pull Request If Needed if: steps.calibre.outputs.markdown != '' uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 #v8.1.0 with: title: Compressed Images Nightly branch-suffix: timestamp commit-message: Compressed Images body: ${{ steps.calibre.outputs.markdown }} ================================================ FILE: .github/workflows/codeql.yml ================================================ name: "CodeQL" on: push: branches: [ "development" ] pull_request: # The branches below must be a subset of the branches above branches: [ "development" ] schedule: - cron: '36 3 * * 5' permissions: {} jobs: analyze: # As this action runs on a schedule, only run it in the FreeTubeApp/FreeTube repository to avoid unnecessary GitHub Actions usage/billing in forks. # We still allow it to run if it was triggered by a pull request or a push as active forks should care about security. # If a fork does need this workflow to run on a schedule, they can change this condition in their fork to include their repository. if: github.repository == 'FreeTubeApp/FreeTube' || github.event_name != 'schedule' name: Analyze (${{ matrix.language }}) runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: include: - language: actions build-mode: none - language: javascript-typescript build-mode: none # https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: persist-credentials: false - name: Initialize CodeQL uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: category: "/language:${{matrix.language}}" ================================================ FILE: .github/workflows/conflicts.yml ================================================ name: "Conflicts" on: # So that PRs touching the same files as the push are updated push: # So that the `dirtyLabel` is removed if conflicts are resolve # We recommend `pull_request_target` so that github secrets are available. # In `pull_request` we wouldn't be able to change labels of fork PRs pull_request_target: types: [synchronize] permissions: {} jobs: main: runs-on: ubuntu-slim permissions: pull-requests: write steps: - name: check if prs are dirty uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 #v3.0.3 with: dirtyLabel: "PR: merge conflicts / rebase needed" removeOnDirtyLabel: "PR: waiting for review" repoToken: "${{ secrets.GITHUB_TOKEN }}" commentOnDirty: "This pull request has conflicts, please resolve those before we can evaluate the pull request." commentOnClean: "Conflicts have been resolved. A maintainer will review the pull request shortly." ================================================ FILE: .github/workflows/flatpak.yml ================================================ # This is a basic workflow that is manually triggered name: Create Flatpak PR # Controls when the action will run. Workflow runs when manually triggered using the UI # or API. on: workflow_dispatch: # Disable running automatically as we now use flathub's external data checker # to automatically open pull requests when we release updates # release: # types: [published] permissions: {} # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: build: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: repository: flathub/io.freetubeapp.FreeTube token: ${{ secrets.FLATHUB_TOKEN }} persist-credentials: true - name: Get Repo Release List uses: moustacheful/github-api-exec-action@2135aaccb1220f81e6fa4f14c90cc20efba069fe #v0 id: list_results with: # Command to execute, (e.g: `pulls.create`), see https://octokit.github.io/rest.js/ for available commands command: repos.listReleases payload: > { "owner": "FreeTubeApp", "repo": "FreeTube" } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install xmlstarlet shell: bash run: sudo apt -y install xmlstarlet - name: Create Version Variable uses: bluwy/substitute-string-action@70ff0b17357670ffd3fee68e95b6de9963b66bad #v3.0.0 id: sub with: _input-text: ${{ fromJson(steps.list_results.outputs.result)[0].tag_name }} -beta: '' v: '' - name: Create Release Branch shell: bash env: VERSION: ${{ steps.sub.outputs.result }} run: | git checkout -b "release-v${VERSION}" git push --set-upstream origin "release-v${VERSION}" - name: Download x64 Release uses: fabriciobastian/download-release-asset-action@35e62aa5a2fe4a6ec79004f6052918349013ae4f #v1.0.6 with: version: v${{ steps.sub.outputs.result }}-beta repository: FreeTubeApp/FreeTube file: freetube-${{ steps.sub.outputs.result }}-beta-linux-x64-portable.zip - name: Download ARM Release uses: fabriciobastian/download-release-asset-action@35e62aa5a2fe4a6ec79004f6052918349013ae4f #v1.0.6 with: version: v${{ steps.sub.outputs.result }}-beta repository: FreeTubeApp/FreeTube file: freetube-${{ steps.sub.outputs.result }}-beta-linux-arm64-portable.zip - name: Set x64 Hash Variable id: hash_x64 shell: bash env: VERSION: ${{ steps.sub.outputs.result }} run: | echo 'HASH_X64<> "$GITHUB_OUTPUT" sha256sum "freetube-${VERSION}-beta-linux-x64-portable.zip" | awk '{print $1}' >> "$GITHUB_OUTPUT" echo 'EOF' >> "$GITHUB_OUTPUT" - name: Set ARM Hash Variable id: hash_arm64 shell: bash env: VERSION: ${{ steps.sub.outputs.result }} run: | echo 'HASH_ARM64<> "$GITHUB_OUTPUT" sha256sum "freetube-${VERSION}-beta-linux-arm64-portable.zip" | awk '{print $1}' >> "$GITHUB_OUTPUT" echo 'EOF' >> "$GITHUB_OUTPUT" - name: Set Date Variable id: current_date shell: bash run: | echo 'CURRENT_DATE<> "$GITHUB_OUTPUT" date +"%Y-%m-%d" >> "$GITHUB_OUTPUT" echo 'EOF' >> "$GITHUB_OUTPUT" - name: Update x64 File Location in yml File shell: bash env: VERSION: steps.sub.outputs.result run: | yq -i '.modules[0].sources[0].url = "https://github.com/FreeTubeApp/FreeTube/releases/download/v" + strenv(VERSION) + "-beta/freetube-" + strenv(VERSION) + "-beta-linux-x64-portable.zip"' io.freetubeapp.FreeTube.yml - name: Update x64 Hash in yml File shell: bash env: HASH_X64: ${{ steps.hash_x64.outputs.HASH_X64 }} run: | yq -i '.modules[0].sources[0].sha256 = strenv(HASH_X64)' io.freetubeapp.FreeTube.yml - name: Update ARM File Location in yml File shell: bash env: VERSION: steps.sub.outputs.result run: | yq -i '.modules[0].sources[1].url = "https://github.com/FreeTubeApp/FreeTube/releases/download/v" + strenv(VERSION) + "-beta/freetube-" + strenv(VERSION) + "-beta-linux-arm64-portable.zip"' io.freetubeapp.FreeTube.yml - name: Update ARM Hash in yml File shell: bash env: HASH_ARM64: ${{ steps.hash_arm64.outputs.HASH_ARM64 }} run: | yq -i '.modules[0].sources[1].sha256 = strenv(HASH_ARM64)' io.freetubeapp.FreeTube.yml - name: Add Patch Notes to XML File shell: bash env: CURRENT_DATE: ${{ steps.current_date.outputs.current_date }} VERSION: ${{ steps.sub.outputs.result }} run: xmlstarlet ed -L -i /component/releases/release[1] -t elem -n releaseTMP -v "" -i //releaseTMP -t attr -n version -v "${VERSION} Beta" -i //releaseTMP -t attr -n date -v "${CURRENT_DATE}" -s //releaseTMP -t elem -n url -v "" -s //releaseTMP/url -t text -n "" -v "https://github.com/FreeTubeApp/FreeTube/releases/tag/v${VERSION}-beta" -r //releaseTMP -v "release" io.freetubeapp.FreeTube.metainfo.xml - name: Remove Release Files shell: bash env: VERSION: ${{ steps.sub.outputs.result }} run: | rm "freetube-${VERSION}-beta-linux-x64-portable.zip" rm "freetube-${VERSION}-beta-linux-arm64-portable.zip" - name: Commit Files uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 #v7.1.0 with: # Optional but recommended # Defaults to "Apply automatic changes" commit_message: Update files for v${{ steps.sub.outputs.result }} # Optional options appended to `git-commit` # See https://git-scm.com/docs/git-commit for a list of available options commit_options: '--no-verify --signoff' # Optional: Disable dirty check and always try to create a commit and push skip_dirty_check: true - name: Create PR shell: bash env: GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }} VERSION: ${{ steps.sub.outputs.result }} run: gh pr create --title "Release v${VERSION}" --body "This is an automated PR for the v${VERSION} release. This PR will be updated and merged once testing is complete." ================================================ FILE: .github/workflows/label-issue.yml ================================================ name: "Issue Labeler" on: issues: types: [opened] permissions: {} jobs: triage: runs-on: ubuntu-slim permissions: contents: read issues: write steps: - uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 #v3.4 with: configuration-path: .github/issue-labeler.yml enable-versioned-regex: 0 repo-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/label-pr.yml ================================================ name: "Pull Request Labeler" on: pull_request_target: types: [opened, reopened, ready_for_review] permissions: {} jobs: triage: permissions: contents: read pull-requests: write runs-on: ubuntu-slim if: ${{ !github.event.pull_request.draft }} steps: - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b #v6.0.1 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" configuration-path: .github/pr-labeler.yml ================================================ FILE: .github/workflows/linter.yml ================================================ # This is a basic workflow to help you get started with Actions name: Linter # Controls when the action will run. Triggers the workflow on push or pull request # events but only for the master branch on: pull_request: branches: [ master, development, '**-RC' ] permissions: {} # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" lint: # The type of runner that the job will run on runs-on: ubuntu-latest permissions: contents: read # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Get yarn cache directory id: cache_dir shell: bash run: | { echo 'cache_dir<> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: persist-credentials: false - name: Use Node.js 24.x uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #v6.3.0 with: node-version: 24.x package-manager-cache: false # This workflow runs on pull requests which are untrusted # so we only restore the yarn cache, we don't save it to avoid cache poisoning - name: Restore yarn cache id: restore_cache uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3 with: key: ${{ format('node-cache-{0}-{1}-yarn-{2}', runner.os, runner.arch, hashFiles('yarn.lock')) }} path: ${{ steps.cache_dir.outputs.cache_dir }} - run: yarn run ci shell: bash env: ELECTRON_SKIP_BINARY_DOWNLOAD: '1' - run: yarn run lint shell: bash # let's verify that webpack is able to package the project - run: yarn run pack shell: bash # verify that webpack is able to package the project using the web config - run: yarn run pack:web shell: bash ================================================ FILE: .github/workflows/no-response.yml ================================================ name: No Response # Both `issue_comment` and `scheduled` event types are required for this Action # to work properly. on: issue_comment: types: [created] schedule: # Run daily at midnight. - cron: '0 0 * * *' permissions: {} jobs: noResponse: # As this action runs on a schedule, only run it in the FreeTubeApp/FreeTube repository to avoid unnecessary GitHub Actions usage/billing in forks. # If a fork does need this workflow, they can change this condition in their fork to include their repository. if: github.repository == 'FreeTubeApp/FreeTube' runs-on: ubuntu-slim permissions: issues: write steps: - uses: lee-dohm/no-response@9bb0a4b5e6a45046f00353d5de7d90fb8bd773bb #v0.5.0 with: token: ${{ github.token }} closeComment: > This issue has been automatically closed because there has been no response to our request for more information from the original author. With only the information that is currently in the issue, we don't have enough information to take action. Please reach out if you have or find the answers we need so that we can investigate further. daysUntilClose: 7 responseRequiredLabel: "U: Waiting for Response from Author" ================================================ FILE: .github/workflows/release.yml ================================================ # This is a basic workflow that is manually triggered name: Upload Release # Controls when the action will run. Workflow runs when manually triggered using the UI # or API. on: workflow_dispatch: inputs: releaseId: type: string required: true description: Release ID permissions: {} # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: build: permissions: contents: write strategy: matrix: node-version: [24.x] runtime: - linux-x64 - linux-armv7l - linux-arm64 - win-x64 - win-arm64 - osx-x64 - osx-arm64 include: - runtime: linux-x64 os: ubuntu-latest - runtime: linux-armv7l os: ubuntu-latest - runtime: linux-arm64 os: ubuntu-latest - runtime: osx-x64 os: macOS-latest - runtime: osx-arm64 os: macOS-latest - runtime: win-x64 os: windows-latest - runtime: win-arm64 os: windows-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: persist-credentials: false # Do not restore the yarn cache, to avoid cache poisoning affecting releases - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #v6.3.0 with: node-version: ${{ matrix.node-version }} package-manager-cache: false - run: yarn run ci shell: bash env: ELECTRON_SKIP_BINARY_DOWNLOAD: '1' - run: yarn run lint shell: bash - name: Get Version Number id: getPackageInfo shell: bash run: | jq --raw-output '"version=" + .version' package.json >> "$GITHUB_OUTPUT" - name: Install libarchive-tools if: startsWith(matrix.os, 'ubuntu') shell: bash run: | sudo apt update sudo apt -y install libarchive-tools - name: Build x64 with Node.js ${{ matrix.node-version}} if: contains(matrix.runtime, 'x64') shell: bash run: yarn run build - name: Build ARMv7l with Node.js ${{ matrix.node-version}} if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') shell: bash run: yarn run build:arm32 - name: Build ARM64 with Node.js ${{ matrix.node-version}} if: contains(matrix.runtime, 'arm64') shell: bash run: yarn run build:arm64 - name: Convert X64 AppImage to static runtime and add update information if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') shell: bash env: VERSION: ${{ steps.getPackageInfo.outputs.version }} run: | sudo apt install desktop-file-utils cd build appimage="FreeTube-${VERSION}.AppImage" wget "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -O ./appimagetool.AppImage chmod +x ./"$appimage" ./appimagetool.AppImage update_information='gh-releases-zsync|FreeTubeApp|FreeTube|latest-all|freetube-*-amd64.AppImage.zsync' ./"$appimage" --appimage-extract && rm -f ./"$appimage" ./appimagetool.AppImage --comp zstd --mksquashfs-opt -Xcompression-level --mksquashfs-opt 20 \ -u "$update_information" -n ./squashfs-root ./"$appimage" rm -rf ./squashfs-root ./appimagetool.AppImage - name: Convert ARMv7l AppImage to static runtime and add update information if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') shell: bash env: VERSION: ${{ steps.getPackageInfo.outputs.version }} run: | sudo apt install desktop-file-utils cd build appimage="FreeTube-${VERSION}.AppImage" wget "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -O ./appimagetool.AppImage wget "https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64" -O runtime chmod +x ./"$appimage" ./appimagetool.AppImage ./runtime update_information='gh-releases-zsync|FreeTubeApp|FreeTube|latest-all|freetube-*-armv7l.AppImage.zsync' TARGET_APPIMAGE="$appimage" ./runtime --appimage-extract && rm -f ./"$appimage" ARCH=armhf ./appimagetool.AppImage --comp zstd --mksquashfs-opt -Xcompression-level --mksquashfs-opt 20 \ -u "$update_information" -n ./squashfs-root ./"$appimage" rm -rf ./squashfs-root ./appimagetool.AppImage ./runtime - name: Convert ARM64 AppImage to static runtime and add update information if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') shell: bash env: VERSION: ${{ steps.getPackageInfo.outputs.version }} run: | sudo apt install desktop-file-utils cd build appimage="FreeTube-${VERSION}.AppImage" wget "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -O ./appimagetool.AppImage wget "https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64" -O runtime chmod +x ./"$appimage" ./appimagetool.AppImage ./runtime update_information='gh-releases-zsync|FreeTubeApp|FreeTube|latest-all|freetube-*-arm64.AppImage.zsync' TARGET_APPIMAGE="$appimage" ./runtime --appimage-extract && rm -f ./"$appimage" ARCH=aarch64 ./appimagetool.AppImage --comp zstd --mksquashfs-opt -Xcompression-level --mksquashfs-opt 20 \ -u "$update_information" -n ./squashfs-root ./"$appimage" rm -rf ./squashfs-root ./appimagetool.AppImage ./runtime - name: Upload Linux .zip x64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-linux-x64-portable.zip asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}.zip asset_content_type: application/zip - name: Upload Linux .7z x64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-linux-x64-portable.7z asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}.7z asset_content_type: application/x-7z-compressed - name: Upload Linux .zip ARMv7l Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-linux-armv7l-portable.zip asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-armv7l.zip asset_content_type: application/zip - name: Upload Linux .7z ARMv7l Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-linux-armv7l-portable.7z asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-armv7l.7z asset_content_type: application/x-7z-compressed - name: Upload Linux .zip ARM64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-linux-arm64-portable.zip asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-arm64.zip asset_content_type: application/zip - name: Upload Linux .7z ARM64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-linux-arm64-portable.7z asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-arm64.7z asset_content_type: application/x-7z-compressed - name: Upload Linux .deb x64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube_${{ steps.getPackageInfo.outputs.version }}_beta_amd64.deb asset_path: build/freetube_${{ steps.getPackageInfo.outputs.version }}_amd64.deb asset_content_type: application/vnd.debian.binary-package - name: Upload Linux .deb ARMv7l Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube_${{ steps.getPackageInfo.outputs.version }}_beta_armv7l.deb asset_path: build/freetube_${{ steps.getPackageInfo.outputs.version }}_armv7l.deb asset_content_type: application/vnd.debian.binary-package - name: Upload Linux .deb ARM64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube_${{ steps.getPackageInfo.outputs.version }}_beta_arm64.deb asset_path: build/freetube_${{ steps.getPackageInfo.outputs.version }}_arm64.deb asset_content_type: application/vnd.debian.binary-package - name: Upload AppImage x64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-amd64.AppImage asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}.AppImage asset_content_type: application/vnd.appimage - name: Upload AppImage .zsync x64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-amd64.AppImage.zsync asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}.AppImage.zsync asset_content_type: application/x-zsync - name: Upload AppImage ARMv7l Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-armv7l.AppImage asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}-armv7l.AppImage asset_content_type: application/vnd.appimage - name: Upload AppImage .zsync ARMv7l Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-armv7l.AppImage.zsync asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}-armv7l.AppImage.zsync asset_content_type: application/x-zsync - name: Upload AppImage ARM64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-arm64.AppImage asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}-arm64.AppImage asset_content_type: application/vnd.appimage - name: Upload AppImage .zsync ARM64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-arm64.AppImage.zsync asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}-arm64.AppImage.zsync asset_content_type: application/x-zsync - name: Upload Linux .rpm x64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta.amd64.rpm asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}.x86_64.rpm asset_content_type: application/x-rpm # rpm are not built for armv7l - name: Upload Linux .rpm ARM64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta.arm64.rpm asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}.aarch64.rpm asset_content_type: application/x-rpm - name: Upload Pacman .pacman x64 Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-amd64.pacman asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}.pacman asset_content_type: application/x-zstd-compressed-tar - name: Upload Windows x64 .exe Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-setup-x64.exe asset_path: build/freetube Setup ${{ steps.getPackageInfo.outputs.version }}.exe asset_content_type: application/x-ms-dos-executable - name: Upload Windows x64 portable Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-win-x64-portable.exe asset_path: build/FreeTube ${{ steps.getPackageInfo.outputs.version }}.exe asset_content_type: application/x-ms-dos-executable - name: Upload Windows x64 .zip Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-win-x64-portable.zip asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-win.zip asset_content_type: application/zip - name: Upload Windows x64 .7z Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-win-x64-portable.7z asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-win.7z asset_content_type: application/x-7z-compressed - name: Upload Windows arm64 .exe Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-setup-arm64.exe asset_path: build/freetube Setup ${{ steps.getPackageInfo.outputs.version }}.exe asset_content_type: application/x-ms-dos-executable - name: Upload Windows arm64 portable Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-win-arm64-portable.exe asset_path: build/FreeTube ${{ steps.getPackageInfo.outputs.version }}.exe asset_content_type: application/x-ms-dos-executable - name: Upload Windows arm64 .zip Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-win-arm64-portable.zip asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-arm64-win.zip asset_content_type: application/zip - name: Upload Windows arm64 .7z Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-win-arm64-portable.7z asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-arm64-win.7z asset_content_type: application/x-7z-compressed - name: Upload Mac x64 .dmg Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-mac-x64.dmg asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}.dmg asset_content_type: application/x-apple-diskimage - name: Upload Mac x64 .zip Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-mac-x64.zip asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-mac.zip asset_content_type: application/zip - name: Upload Mac x64 .7z Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-mac-x64.7z asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-mac.7z asset_content_type: application/x-7z-compressed - name: Upload Mac arm64 .dmg Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-mac-arm64.dmg asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-arm64.dmg asset_content_type: application/x-apple-diskimage - name: Upload Mac arm64 .zip Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-mac-arm64.zip asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-arm64-mac.zip asset_content_type: application/x-apple-diskimage - name: Upload Mac arm64 .7z Release uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 #v1.0.2 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label} asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-mac-arm64.7z asset_path: build/freetube-${{ steps.getPackageInfo.outputs.version }}-arm64-mac.7z asset_content_type: application/x-7z-compressed ================================================ FILE: .github/workflows/remove-outdated-labels.yml ================================================ name: Remove outdated labels on: pull_request_target: types: - closed - converted_to_draft - ready_for_review permissions: {} jobs: remove-closed-pr-labels: name: Remove closed and merged pull request labels if: github.event.action == 'closed' runs-on: ubuntu-slim permissions: pull-requests: write steps: - name: Remove labels shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_URL: ${{ github.event.pull_request.html_url }} run: | labels='PR: waiting for review,' labels+='PR: WIP,' labels+='PR: changes requested,' labels+='PR: merge conflicts / rebase needed,' labels+='PR/Issue: dependent,' labels+='PR: stale' gh pr edit "$PR_URL" --remove-label "$labels" remove-draft-pr-labels: name: Remove labels from draft pull requests if: | github.event.action == 'converted_to_draft' && contains(github.event.pull_request.labels.*.name, 'PR: waiting for review') runs-on: ubuntu-slim permissions: pull-requests: write steps: - name: Remove label shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_URL: ${{ github.event.pull_request.html_url }} run: | gh pr edit "$PR_URL" --remove-label 'PR: waiting for review' remove-ready-pr-labels: name: Remove labels when draft pr is marked ready for review if: | github.event.action == 'ready_for_review' && contains(github.event.pull_request.labels.*.name, 'PR: WIP') runs-on: ubuntu-slim permissions: pull-requests: write steps: - name: Remove label shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_URL: ${{ github.event.pull_request.html_url }} run: | gh pr edit "$PR_URL" --remove-label 'PR: WIP' ================================================ FILE: .github/workflows/stale.yml ================================================ name: 'Close stale PRs' on: schedule: - cron: '30 1 * * *' permissions: {} jobs: stale: # As this action runs on a schedule, only run it in the FreeTubeApp/FreeTube repository to avoid unnecessary GitHub Actions usage/billing in forks. # If a fork does need this workflow, they can change this condition in their fork to include their repository. if: github.repository == 'FreeTubeApp/FreeTube' runs-on: ubuntu-slim permissions: actions: write pull-requests: write steps: - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f #v10.2.0 with: stale-pr-message: 'This PR is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 14 days.' close-pr-message: 'This PR was closed because it has been stalled for 14 days with no activity.' days-before-pr-stale: 14 days-before-pr-close: 14 stale-pr-label: 'PR: stale' exempt-pr-labels: 'PR: WIP' # actions/stale doesn't have a way of running only on pull requests # so set the issue thresholds to -1 so it at least doesn't try to label or close them days-before-stale: -1 days-before-close: -1 ================================================ FILE: .github/workflows/updateSite.yml ================================================ # This is a basic workflow that is manually triggered name: Update Site Version Number # Controls when the action will run. Workflow runs when manually triggered using the UI # or API. on: workflow_dispatch: release: types: [published] permissions: {} # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: build: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: repository: FreeTubeApp/FreeTubeApp.io token: ${{ secrets.FLATHUB_TOKEN }} persist-credentials: true - name: Get Repo Release List uses: moustacheful/github-api-exec-action@2135aaccb1220f81e6fa4f14c90cc20efba069fe #v0 id: list_results with: # Command to execute, (e.g: `pulls.create`), see https://octokit.github.io/rest.js/ for available commands command: repos.listReleases payload: > { "owner": "FreeTubeApp", "repo": "FreeTube" } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create Current Version Variable uses: bluwy/substitute-string-action@70ff0b17357670ffd3fee68e95b6de9963b66bad #v3.0.0 id: current with: _input-text: ${{ fromJson(steps.list_results.outputs.result)[0].tag_name }} -beta: '' v: '' - name: Create Previous Version Variable uses: bluwy/substitute-string-action@70ff0b17357670ffd3fee68e95b6de9963b66bad #v3.0.0 id: previous with: _input-text: ${{ fromJson(steps.list_results.outputs.result)[1].tag_name }} -beta: '' v: '' - name: Set Master Branch # Currently the default branch is master, but if that changes later, then this acts as a failsafe. shell: bash run: | git checkout master - name: Update index.php shell: bash env: CURRENT: ${{ steps.current.outputs.result }} PREVIOUS: ${{ steps.previous.outputs.result }} run: | sed -i "s/${PREVIOUS}/${CURRENT}/g" src/index.php - name: Commit Files uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 #v7.1.0 with: # Optional but recommended # Defaults to "Apply automatic changes" commit_message: Update version number to v${{ steps.current.outputs.result }} # Optional options appended to `git-commit` # See https://git-scm.com/docs/git-commit for a list of available options commit_options: '--no-verify --signoff' # Optional: Disable dirty check and always try to create a commit and push skip_dirty_check: true ================================================ FILE: .gitignore ================================================ .DS_Store dist/electron/* storyboards/* dashFiles/* static/dashFiles static/storyboards static/dashFiles/* static/storyboards/* dist/web/* build/* !build/icons coverage node_modules/ npm-debug.log npm-debug.log.* thumbs.db !.gitkeep data/tmp/ .tmp/ tmp/ .cache dist coverage __coverage__ csak-timelog.json .idea/ debug/ # Lefthook lefthook-local.yml locale-errors.json ================================================ FILE: .stylelintignore ================================================ src/data/ src/datastores/ src/main/ src/renderer/videoJS.css dist/ static/ node_modules/ ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "editorconfig.editorconfig", "dbaeumer.vscode-eslint", "stylelint.vscode-stylelint", "syler.sass-indented", "redhat.vscode-yaml", "vue.volar", "eamodio.gitlens" ] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "dev-runner (Electron)", "type": "node", "request": "launch", "program": "${workspaceFolder}/_scripts/dev-runner.js", "args": ["--remote-debug"] }, { "name": "Attach to renderer process (Electron)", "type": "chrome", "request": "attach", "port": 9223, "webRoot": "http://localhost:9080", "sourceMapPathOverrides": { "webpack://freetube/./~/*": "${workspaceFolder}/node_modules/*", "webpack://freetube/./*": "${workspaceFolder}/*" } } ] } ================================================ FILE: .vscode/settings.json ================================================ { "stylelint.packageManager": "yarn", "stylelint.snippet": [ "css", "less", "postcss", "sass", "scss" ], "stylelint.validate": [ "css", "less", "postcss", "scss" ], "eslint.packageManager": "yarn", "eslint.probe": [ "javascript", "vue", "json", "jsonc", "yml", "yaml" ], "eslint.validate": [ "javascript", "vue", "json", "jsonc", "yml", "yaml" ], "javascript.preferences.importModuleSpecifier": "relative" } ================================================ FILE: .whitesource ================================================ ########################################################## #### WhiteSource "Bolt for Github" configuration file #### ########################################################## # Configuration # #---------------# ws.repo.scan=true vulnerable.check.run.conclusion.level=failure ================================================ FILE: CONTRIBUTING.md ================================================ # Code Contributions ## Before starting to code Please follow these guidelines before starting to code you feature or bugfix. * If you want to implement a bugfix or feature request from an existing issue, please comment on that issue that you will work on it. This helps us to coordinate what needs to be done and what not. * If you want to implement a feature request without an existing issue, please create an issue, so we know what you are working on and discuss the feature. * For major feature implementations make sure you are able to maintain your code in the future in regard to bugs and possible code conflicts in future updates. Optionally you could join the [Matrix](https://matrix.to/#/#freetube:matrix.org) channel, so you will hear instantly if something needs to be worked on. * Do not open pull requests that are primarily written by AI. It's a waste of our team's time. Repeat offenders will be banned. ## Before your Pull Request Please follow these guidelines before sending your pull request and making contributions. * When you submit a pull request, you agree that your code is published under the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) * Please link the issue you are referring to. * Do not include non-free software or modules with your code. * Make sure your pull request is set up to merge your branch to FreeTube's development branch. * Make sure your branch is up to date with the development branch before submitting your pull request. * Stick to a similar style of code already in the project. Please look at current code to get an idea on how to do this. * Follow [ES6](https://rse.github.io/es6-features/) standards in your code. Ex: Use `let` and `const` instead of `var`. Do not use `function(response){//code}` for callbacks, use `(response) => {//code}`. * Comment your code when necessary. Follow the [JavaScript Documentation and Comments Standard](https://www.drupal.org/docs/develop/standards/javascript/javascript-api-documentation-and-comment-standards) for functions. * Please follow proper Vue structure when creating new code / components. Use existing code as well as the [Vue.js Guide](https://vuejs.org/guide/introduction.html) for reference. * Please test your code. Make sure new features work as well as existing core features such as watching videos or loading subscriptions. New features need to work with both the Local API as well as the Invidious API * Please make sure your code does not violate any standards set by our linter. It's up to you to make fixes whenever necessary. You can run `yarn run lint` to check locally and `yarn run lint-fix` to automatically fix smaller issues. * Please limit the amount of Node Modules that you introduce into the project. Only include them when **absolutely necessary** for your code to work (Ex: Using nedb for databases) or if a module provides similar functionality to what you are trying to achieve (Ex: Using autolinker to create links to outside URLs instead of writing the functionality myself). * Please try to stay involved with the community and maintain your code. We are only a handful of developers working on FreeTube in our spare time. We do not have time to work on everything, and it would be nice if you can maintain your code when necessary. # Setting up Your Environment Check out the [wiki](https://docs.freetubeapp.io/development/getting-started/) page to learn how to set up your environment and get started. ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================

FreeTube is an open source desktop YouTube player built with privacy in mind. Use YouTube without advertisements and prevent Google from tracking you with their cookies and JavaScript. Available for Windows (10 and later), Mac (macOS 12 and later) & Linux thanks to Electron.

Download FreeTube

Build status Translation status


ScreenshotsHow does it work?FeaturesDownload LinksContributingLocalizationContactDonateLicense

WebsiteBlogDocumentationFAQDiscussions


> [!NOTE] > FreeTube is currently in Beta. While it should work well for most users, there are still bugs and missing features that need to be addressed. > > If you have an idea or if you found a bug, please submit a [GitHub issue](https://github.com/FreeTubeApp/FreeTube/issues/new/choose) so that we can track it. Please [search the existing issues](https://github.com/FreeTubeApp/FreeTube/issues?q=is%3Aissue+sort%3Arelevance-desc) before submitting to prevent duplicates! ## Screenshots | The main FreeTube window | |--------------------------------------------------------------------------------------------------| | ![](https://raw.githubusercontent.com/FreeTubeApp/FreeTubeApp.io/master/src/images/FreeTube1.png)| | Watching a video | |--------------------------------------------------------------------------------------------------| | ![](https://raw.githubusercontent.com/FreeTubeApp/FreeTubeApp.io/master/src/images/FreeTube2.png)| | Settings | |--------------------------------------------------------------------------------------------------| | ![](https://raw.githubusercontent.com/FreeTubeApp/FreeTubeApp.io/master/src/images/FreeTube3.png)| ## How does it work? FreeTube uses a built in extractor to grab and serve data / videos. The [Invidious API](https://github.com/iv-org/invidious) can also optionally be used. FreeTube does not use any official APIs to obtain data. While YouTube can still see your video requests, it can no longer track you using cookies or JavaScript. Your subscriptions, playlists and history are stored locally on your computer and never sent out. > [!IMPORTANT] > Using a VPN or Tor is highly recommended to hide your IP while using FreeTube. ## Features * Watch videos without ads * Use YouTube without Google tracking you using cookies and JavaScript * Two extractor APIs to choose from (Built in or Invidious) * Subscribe to channels without an account * Connect to an externally setup proxy such as Tor * View and search your local subscriptions, playlists and history * Organize your subscriptions into "Profiles" to create a more focused feed * Export & import subscriptions * YouTube Trending * YouTube Chapters * Most popular videos page based on the set Invidious instance * SponsorBlock * DeArrow * Open videos from your browser directly into FreeTube (with extension) * Watch videos using an external player * Full Theme support * Make a screenshot of a video * Multiple windows * Mini Player (Picture-in-Picture) * Keyboard shortcuts * Option to show only family friendly content * Show/hide functionality or elements within the app using the distraction free settings * View channel posts ### Browser Extensions The following extensions open YouTube links directly in FreeTube: - [LibRedirect](https://libredirect.github.io/) - [RedirectTube](https://github.com/MStankiewiczOfficial/RedirectTube) LibRedirect automatically redirect YouTube links to FreeTube. > [!IMPORTANT] > To ensure proper functionality, select FreeTube as Frontend in the Services settings of the extension. RedirectTube, doesn’t automatically open YouTube links in FreeTube. Instead, it adds buttons to the toolbar and context menu, which you can click to open videos in FreeTube manually. - Download LibRedirect from [Mozilla Add-ons](https://addons.mozilla.org/firefox/addon/libredirect/) (for Firefox based-browsers) or [developer's website](https://libredirect.github.io/download_chromium.html) (for Chrome and Chromium-based browsers). - Download RedirectTube from [Mozilla Add-ons](https://addons.mozilla.org/firefox/addon/redirecttube/) (for Firefox based-browsers). > [!NOTE] > These extensions do not work on Linux portable builds! > > If you have issues with the extension working with FreeTube, please create an issue in this repository instead of the extension repository. ## Download Links ### Official Downloads > [!CAUTION] > FreeTube is only supported on Windows 10 and later, macOS 12 and above, and various Linux distributions. Installing it on unsupported systems may result in unexpected issues. * [GitHub Releases](https://github.com/FreeTubeApp/FreeTube/releases) * [FreeTube Website](https://freetubeapp.io/#download) * Flatpak on Flathub: [Download](https://flathub.org/apps/details/io.freetubeapp.FreeTube) and [Source Code](https://github.com/flathub/io.freetubeapp.FreeTube) #### Automated Builds (Nightly / Weekly) > [!WARNING] > Use these builds at your own risk. These are pre-release versions and are only intended for people that want to test changes early and are willing to accept that things could break from one build to another. Builds are automatically created from changes to our development branch via [GitHub Actions](https://github.com/FreeTubeApp/FreeTube/actions?query=workflow%3ABuild). The first build with a green check mark is the latest build. > [!IMPORTANT] > You will need to have a GitHub account to download these builds. ### Unofficial Downloads > [!WARNING] > These builds are maintained by the community. While they should be safe, download at your own risk. There may be issues with using these versus the official builds. Any issues specific with these builds should be sent to their respective maintainer. Make sure you always try an [official download](https://github.com/freetubeapp/freetube/#official-downloads) before reporting your issue to us! * Arch User Repository (AUR): [Download](https://aur.archlinux.org/packages/freetube-bin/) * Chocolatey: [Download](https://chocolatey.org/packages/freetube/) * FreeTubeAndroid (FreeTube port for Android and PWA): [Download](https://github.com/MarmadileManteater/FreeTubeAndroid/releases) and [Source Code](https://github.com/MarmadileManteater/FreeTubeAndroid) * Homebrew Formulae (Mac only): [Download for Apple Silicon](https://github.com/PikachuEXE/homebrew-FreeTube) * Nix Packages: [Download](https://search.nixos.org/packages?query=freetube) * PortableApps (Windows Only): [Download](https://github.com/rddim/FreeTubePortable/releases) and [Source Code](https://github.com/rddim/FreeTubePortable) * Scoop (Windows Only): [Usage](https://github.com/ScoopInstaller/Scoop) * Snap: [Download](https://snapcraft.io/freetube) and [Source Code](https://git.launchpad.net/freetube) * WAPT: [Download](https://wapt.tranquil.it/store/en/tis-freetube) * Windows Package Manager (winget): [Usage](https://docs.microsoft.com/en-us/windows/package-manager/winget/) ## Contributing Thank you very much to the [People and Projects](https://docs.freetubeapp.io/credits/) that make FreeTube possible! If you like to get your hands dirty and want to contribute, we would love to have your help. Send a pull request and someone will review your code. > [!IMPORTANT] > Please follow the [Contribution Guidelines](https://github.com/FreeTubeApp/FreeTube/blob/development/CONTRIBUTING.md) before sending your pull request. ## Localization Translation status We are actively looking for translations! We use [Weblate](https://hosted.weblate.org/engage/free-tube/) to make it easy for translators to get involved. Click on the badge above to learn how to get involved. For the Linux Flatpak, the desktop entry comment string can be translated at our [Flatpak repository](https://github.com/flathub/io.freetubeapp.FreeTube/blob/master/io.freetubeapp.FreeTube.desktop). ## Contact If you ever have any questions, feel free to ask it on our [Discussions](https://github.com/FreeTubeApp/FreeTube/discussions) page. Alternatively, you can email us at FreeTubeApp@protonmail.com or you can join our [Matrix Room](https://matrix.to/#/#freetube:matrix.org). > [!IMPORTANT] > Don't forget to check out the [rules](https://docs.freetubeapp.io/community/matrix/) before joining. ## Donate If you enjoy using FreeTube, you're welcome to leave a donation using the following method. * Bitcoin Address: `1Lih7Ho5gnxb1CwPD4o59ss78pwo2T91eS` While your donations are much appreciated, only donate if you really want to. Donations are used for keeping the website up and running and eventual code signing costs. > [!TIP] > If you are using the Invidious API then we recommend that you donate to the instance that you use. You can also donate to the [Invidious team](https://invidious.io/donate/) or the [Local API developer](https://github.com/sponsors/LuanRT). ## License [![GNU AGPLv3 Image](https://www.gnu.org/graphics/agplv3-155x51.png)](https://www.gnu.org/licenses/agpl-3.0.html) FreeTube is Free Software: You can use, study share and improve it at your will. Specifically you can redistribute and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. ================================================ FILE: _scripts/ProcessLocalesPlugin.js ================================================ const { existsSync, readFileSync } = require('fs') const { readFile } = require('fs/promises') const { join } = require('path') const { brotliCompress, constants } = require('zlib') const { promisify } = require('util') const { load: loadYaml } = require('js-yaml') const brotliCompressAsync = promisify(brotliCompress) const PLUGIN_NAME = 'ProcessLocalesPlugin' class ProcessLocalesPlugin { constructor(options = {}) { this.compress = !!options.compress this.hotReload = !!options.hotReload if (typeof options.inputDir !== 'string') { throw new Error('ProcessLocalesPlugin: no input directory `inputDir` specified.') } else if (!existsSync(options.inputDir)) { throw new Error('ProcessLocalesPlugin: the specified input directory does not exist.') } this.inputDir = options.inputDir if (typeof options.outputDir !== 'string') { throw new Error('ProcessLocalesPlugin: no output directory `outputDir` specified.') } this.outputDir = options.outputDir /** @type {Map} */ this.locales = new Map() this.localeNames = [] /** @type {Map} */ this.cache = new Map() this.filePaths = [] this.previousTimestamps = new Map() this.startTime = Date.now() /** @type {(updatedLocales: [string, string][]) => void|null} */ this.notifyLocaleChange = null this.loadLocales() } /** @param {import('webpack').Compiler} compiler */ apply(compiler) { const { Compilation, DefinePlugin } = compiler.webpack new DefinePlugin({ 'process.env.HOT_RELOAD_LOCALES': this.hotReload }).apply(compiler) compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { compilation.hooks.processAssets.tapPromise({ name: PLUGIN_NAME, stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL }, async (_assets) => { // While running in the webpack dev server, this hook gets called for every incremental build. // For incremental builds we can return the already processed versions, which saves time // and makes webpack treat them as cached /** @type {[string, string][]} */ const updatedLocales = [] if (this.hotReload && !this.notifyLocaleChange) { console.warn('ProcessLocalesPlugin: Unable to live reload locales as `notifyLocaleChange` is not set.') } const promises = [] for (const [locale, data] of this.locales) { promises.push(this.processLocale(locale, data, updatedLocales, compiler, compilation)) } await Promise.all(promises) if (this.hotReload && this.notifyLocaleChange && updatedLocales.length > 0) { this.notifyLocaleChange(updatedLocales) } }) }) compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => { if (compiler.watching) { // watch locale files for changes compilation.fileDependencies.addAll(this.filePaths) } }) } /** * @param {string} locale * @param {any} data * @param {[string, string][]} updatedLocales * @param {import('webpack').Compiler} compiler * @param {import('webpack').Compilation} compilation */ async processLocale(locale, data, updatedLocales, compiler, compilation) { if (compiler.watching && compiler.fileTimestamps) { const filePath = join(this.inputDir, `${locale}.yaml`) const timestamp = compiler.fileTimestamps.get(filePath)?.safeTime if (timestamp && timestamp > (this.previousTimestamps.get(locale) ?? this.startTime)) { this.previousTimestamps.set(locale, timestamp) const contents = await readFile(filePath, 'utf-8') data = loadYaml(contents) } else { const { filename, source } = this.cache.get(locale) compilation.emitAsset(filename, source, { minimized: true }) return } } this.removeEmptyValues(data) let filename = `${this.outputDir}/${locale}.json` let output = JSON.stringify(data) if (this.hotReload && compiler.fileTimestamps) { updatedLocales.push([locale, output]) } if (this.compress) { filename += '.br' output = await this.compressLocale(output) } let source = new compiler.webpack.sources.RawSource(output) if (compiler.watching) { source = new compiler.webpack.sources.CachedSource(source) this.cache.set(locale, { filename, source }) // we don't need the unmodified sources anymore, as we use the cache `this.cache` // so we can clear this to free some memory this.locales.set(locale, null) } compilation.emitAsset(filename, source, { minimized: true }) } loadLocales() { const activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`)) for (const locale of activeLocales) { const filePath = join(this.inputDir, `${locale}.yaml`) this.filePaths.push(filePath) const contents = readFileSync(filePath, 'utf-8') const data = loadYaml(contents) this.locales.set(locale, data) this.localeNames.push(data['Locale Name'] ?? locale) } } async compressLocale(data) { const buffer = Buffer.from(data, 'utf-8') return await brotliCompressAsync(buffer, { params: { [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT, [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, [constants.BROTLI_PARAM_SIZE_HINT]: buffer.byteLength } }) } /** * vue-i18n doesn't fallback if the translation is an empty string * so we want to get rid of them and also remove the empty objects that can get left behind * if we've removed all the keys and values in them * @param {object|string} data */ removeEmptyValues(data) { for (const key of Object.keys(data)) { const value = data[key] if (typeof value === 'object') { this.removeEmptyValues(value) } if (!value || (typeof value === 'object' && Object.keys(value).length === 0)) { delete data[key] } } } } module.exports = ProcessLocalesPlugin ================================================ FILE: _scripts/_undefinedDefaultExport.mjs ================================================ export default undefined ================================================ FILE: _scripts/build.mjs ================================================ import { Arch, build, Platform } from 'electron-builder' import config from './ebuilder.config.mjs' const args = process.argv /** @type {Map>>} */ let targets const platform = process.platform if (platform === 'darwin') { let arch = Arch.x64 if (args[2] === 'arm64') { arch = Arch.arm64 } targets = Platform.MAC.createTarget(['DMG', 'zip', '7z'], arch) } else if (platform === 'win32') { let arch = Arch.x64 if (args[2] === 'arm64') { arch = Arch.arm64 } targets = Platform.WINDOWS.createTarget(['nsis', 'zip', '7z', 'portable'], arch) } else if (platform === 'linux') { let arch = Arch.x64 if (args[2] === 'arm64') { arch = Arch.arm64 } if (args[2] === 'arm32') { arch = Arch.armv7l } targets = Platform.LINUX.createTarget(['deb', 'zip', '7z', 'rpm', 'AppImage', 'pacman'], arch) } const output = await build({ targets, config, publish: 'never' }) console.log(output) ================================================ FILE: _scripts/clean.mjs ================================================ import { rm } from 'fs/promises' import { join } from 'path' const BUILD_PATH = join(import.meta.dirname, '..', 'build') const DIST_PATH = join(import.meta.dirname, '..', 'dist') await Promise.all([ rm(BUILD_PATH, { recursive: true, force: true }), rm(DIST_PATH, { recursive: true, force: true }) ]) ================================================ FILE: _scripts/dev-runner.js ================================================ process.env.NODE_ENV = 'development' const electron = require('electron') const webpack = require('webpack') const WebpackDevServer = require('webpack-dev-server') const kill = require('tree-kill') const path = require('path') const { spawn } = require('child_process') const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') let electronProcess = null let manualRestart = null const remoteDebugging = process.argv.indexOf('--remote-debug') !== -1 const web = process.argv.indexOf('--web') !== -1 let mainConfig let rendererConfig let preloadConfig let botGuardScriptConfig let webConfig let SHAKA_LOCALES_TO_BE_BUNDLED if (!web) { mainConfig = require('./webpack.main.config') rendererConfig = require('./webpack.renderer.config') preloadConfig = require('./webpack.preload.config.js') botGuardScriptConfig = require('./webpack.botGuardScript.config') SHAKA_LOCALES_TO_BE_BUNDLED = rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED delete rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED } else { webConfig = require('./webpack.web.config') } if (remoteDebugging) { // disable dvtools open in electron process.env.RENDERER_REMOTE_DEBUGGING = true } // Define exit code for relaunch and set it in the environment const relaunchExitCode = 69 process.env.FREETUBE_RELAUNCH_EXIT_CODE = relaunchExitCode const port = 9080 async function killElectron(pid) { return new Promise((resolve, reject) => { if (pid) { kill(pid, err => { if (err) reject(err) resolve() }) } else { resolve() } }) } async function restartElectron() { console.log('\nStarting electron...') const { pid } = electronProcess || {} await killElectron(pid) electronProcess = spawn(electron, [ path.join(__dirname, '../dist/main.js'), // '--enable-logging', // Enable to show logs from all electron processes remoteDebugging ? '--inspect=9222' : '', remoteDebugging ? '--remote-debugging-port=9223' : '' ], // { stdio: 'inherit' } // required for logs to actually appear in the stdout ) electronProcess.on('exit', (code, _) => { if (code === relaunchExitCode) { electronProcess = null restartElectron() return } if (!manualRestart) process.exit(0) }) } /** * @param {import('webpack').Compiler} compiler * @param {WebpackDevServer} devServer */ function setupNotifyLocaleUpdate(compiler, devServer) { const notifyLocaleChange = (updatedLocales) => { devServer.sendMessage(devServer.webSocketServer.clients, 'freetube-locale-update', updatedLocales) } compiler.options.plugins .filter(plugin => plugin instanceof ProcessLocalesPlugin) .forEach((/** @type {ProcessLocalesPlugin} */plugin) => { plugin.notifyLocaleChange = notifyLocaleChange }) } function startBotGuardScript() { webpack(botGuardScriptConfig, (err) => { if (err) console.error(err) console.log(`\nCompiled ${botGuardScriptConfig.name} script!`) }) } function startMain() { const compiler = webpack(mainConfig) const { name } = compiler compiler.hooks.afterEmit.tap('afterEmit', async () => { console.log(`\nCompiled ${name} script!`) manualRestart = true await restartElectron() setTimeout(() => { manualRestart = false }, 2500) console.log(`\nWatching file changes for ${name} script...`) }) compiler.watch({ aggregateTimeout: 500, }, err => { if (err) console.error(err) }) } function startPreload() { const compiler = webpack(preloadConfig) const { name } = compiler let firstTime = true compiler.hooks.afterEmit.tap('afterEmit', async () => { console.log(`\nCompiled ${name} script!`) if (firstTime) { firstTime = false } else { manualRestart = true await restartElectron() setTimeout(() => { manualRestart = false }, 2500) } console.log(`\nWatching file changes for ${name} script...`) }) compiler.watch({ aggregateTimeout: 500, }, err => { if (err) console.error(err) }) } function startRenderer(callback) { const compiler = webpack(rendererConfig) const { name } = compiler compiler.hooks.afterEmit.tap('afterEmit', () => { console.log(`\nCompiled ${name} script!`) console.log(`\nWatching file changes for ${name} script...`) }) const server = new WebpackDevServer({ client: { overlay: { runtimeErrors: false } }, static: [ { directory: path.resolve(__dirname, '..', 'static'), watch: { ignored: [ /(dashFiles|storyboards)\/*/, '/**/.DS_Store', '**/static/locales/*' ] }, publicPath: '/static' }, { directory: path.resolve(__dirname, '..', 'node_modules', 'shaka-player', 'ui', 'locales'), publicPath: '/static/shaka-player-locales', watch: { // Ignore everything that isn't one of the locales that we would bundle in production mode ignored: `**/!(${SHAKA_LOCALES_TO_BE_BUNDLED.join('|')}).json` } } ], port }, compiler) server.startCallback(err => { if (err) console.error(err) setupNotifyLocaleUpdate(compiler, server) callback() }) } function startWeb () { const compiler = webpack(webConfig) const { name } = compiler compiler.hooks.afterEmit.tap('afterEmit', () => { console.log(`\nCompiled ${name} script!`) console.log(`\nWatching file changes for ${name} script...`) }) const server = new WebpackDevServer({ open: true, static: { directory: path.resolve(__dirname, '..', 'static'), watch: { ignored: [ /(dashFiles|storyboards)\/*/, '/**/.DS_Store', '**/static/locales/*' ] } }, port }, compiler) server.startCallback(err => { if (err) console.error(err) setupNotifyLocaleUpdate(compiler, server) }) } if (!web) { startRenderer(() => { startBotGuardScript() startPreload() startMain() }) } else { startWeb() } ================================================ FILE: _scripts/ebuilder.config.mjs ================================================ import packageDetails from '../package.json' with { type: 'json' } /** @type {import('electron-builder').Configuration} */ export default { appId: `io.freetubeapp.${packageDetails.name}`, copyright: 'Copyleft © 2020-2026 freetubeapp@protonmail.com', // asar: false, // compression: 'store', productName: packageDetails.productName, directories: { output: './build/', }, protocols: [ { name: 'FreeTube', schemes: [ 'freetube' ] } ], files: [ '_icons/iconColor.*', 'icon.svg', './dist/**/*', '!dist/web/*', '!node_modules/**/*', ], // As we bundle all dependecies with webpack, the `node_modules` folder is excluded from packaging in the `files` array. // electron-builder will however still spend time scanning the `node_modules` folder and building up a list of dependencies, // returning `false` from the `beforeBuild` hook skips that. beforeBuild: () => Promise.resolve(false), dmg: { contents: [ { path: '/Applications', type: 'link', x: 410, y: 230, }, { type: 'file', x: 130, y: 230, }, ], window: { height: 380, width: 540, } }, linux: { category: 'Network', icon: '_icons/icon.svg', target: ['deb', 'zip', '7z', 'rpm', 'AppImage', 'pacman'], }, // See the following issues for more information // https://github.com/jordansissel/fpm/issues/1503 // https://github.com/jgraph/drawio-desktop/issues/259 rpm: { fpm: ['--rpm-rpmbuild-define=_build_id_links none'] }, deb: { depends: [ 'libgtk-3-0', 'libnotify4', 'libnss3', 'libxss1', 'libxtst6', 'xdg-utils', 'libatspi2.0-0', 'libuuid1', 'libsecret-1-0' ] }, mac: { category: 'public.app-category.utilities', icon: '_icons/iconMac.icns', target: ['dmg', 'zip', '7z'], type: 'distribution', extendInfo: { CFBundleURLTypes: [ 'freetube' ], CFBundleURLSchemes: [ 'freetube' ], // Clear the default usage descriptions in the Info.plist file set by Electron that we don't need // see: https://github.com/electron/electron/blob/main/shell/browser/resources/mac/Info.plist NSAudioCaptureUsageDescription: undefined, NSBluetoothAlwaysUsageDescription: undefined, NSBluetoothPeripheralUsageDescription: undefined, NSCameraUsageDescription: undefined, NSMicrophoneUsageDescription: undefined, } }, win: { icon: '_icons/icon.ico', target: ['nsis', 'zip', '7z', 'portable'], }, nsis: { allowToChangeInstallationDirectory: true, oneClick: false, }, } ================================================ FILE: _scripts/eslint-rules/plugin.mjs ================================================ import preferUseI18nPolyfillRule from './prefer-use-i18n-polyfill-rule.mjs' export default { meta: { name: 'eslint-plugin-freetube', version: '1.0' }, rules: { 'prefer-use-i18n-polyfill': preferUseI18nPolyfillRule } } ================================================ FILE: _scripts/eslint-rules/prefer-use-i18n-polyfill-rule.mjs ================================================ import { dirname, relative, resolve } from 'path' const polyfillPath = resolve(import.meta.dirname, '../../src/renderer/composables/use-i18n-polyfill') function getRelativePolyfillPath(filePath) { const relativePath = relative(dirname(filePath), polyfillPath).replaceAll('\\', '/') if (relativePath[0] !== '.') { return `./${relativePath}` } return relativePath } /** @type {import('eslint').Rule.RuleModule} */ export default { meta: { type: 'problem', fixable: 'code' }, create(context) { return { 'ImportDeclaration[source.value="vue-i18n"]'(node) { const specifierIndex = node.specifiers.findIndex(specifier => specifier.type === 'ImportSpecifier' && specifier.imported.name === 'useI18n') if (specifierIndex !== -1) { context.report({ node: node.specifiers.length === 1 ? node : node.specifiers[specifierIndex], message: "Please use FreeTube's useI18n polyfill, as vue-i18n's useI18n composable does not work when the vue-i18n is in legacy mode, which is needed for components using the Options API.", fix: context.physicalFilename === '' ? undefined : (fixer) => { const relativePath = getRelativePolyfillPath(context.physicalFilename) // If the import only imports `useI18n`, we can just update the source/from text // Else we need to create a new import for `useI18n` and remove useI18n from the original one if (node.specifiers.length === 1) { return fixer.replaceText(node.source, `'${relativePath}'`) } else { const specifier = node.specifiers[specifierIndex] let specifierText = 'useI18n' if (specifier.imported.name !== specifier.local.name) { specifierText += ` as ${specifier.local.name}` } return [ fixer.removeRange([ specifierIndex === 0 ? specifier.start : node.specifiers[specifierIndex - 1].end, specifierIndex === node.specifiers.length - 1 ? specifier.end : node.specifiers[specifierIndex + 1].start ]), fixer.insertTextAfter(node, `\nimport { ${specifierText} } from '${relativePath}'`) ] } } }) } } } } } ================================================ FILE: _scripts/findMissingTemplates.mjs ================================================ import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { load as loadYaml } from 'js-yaml' const localesPath = join(import.meta.dirname, '..', 'static', 'locales') const defaultLocale = 'en-US.yaml' const errors = [ ] const defaultData = loadYaml(readFileSync(`${localesPath}/${defaultLocale}`, { encoding: 'utf-8' })) const defaultKeys = Object.keys(defaultData) const filesInLocaleDir = readdirSync(localesPath) for (const file of filesInLocaleDir) { if (file !== defaultLocale && file.endsWith('.yaml')) { const fileData = loadYaml(readFileSync(`${localesPath}/${file}`, { encoding: 'utf-8' })) const fileDataKeys = Object.keys(fileData) addErrors(defaultData, fileData, defaultKeys, fileDataKeys, file) } } writeFileSync('locale-errors.json', JSON.stringify(errors, null, 2)) if (errors.length > 0) { console.error(errors) } else { console.log('no issues found') } /** * @param {unknown} originalData - data from en-US converted to a JavaScript object * @param {unknown} newData - data from the file we are analyzing converted to a JavaScript object * @param {string[]} originalKeys - keys from en-US file * @param {string[]} newKeys - keys from the file we are currently analyzing * @param {string} file - the file we are currently analyzing */ function addErrors(originalData, newData, originalKeys, newKeys, file) { newKeys.forEach(newKey => { if (originalKeys.includes(newKey)) { if (typeof originalData[newKey] === 'object') { addErrors(originalData[newKey], newData[newKey], Object.keys(originalData[newKey]), Object.keys(newData[newKey]), file) } else if (isMissingInterpolation(originalData[newKey], newData[newKey], file)) { errors.push({ fileName: file, error: 'value is missing a template or has an extra template', key: newKey, defaultValue: originalData[newKey], value: newData[newKey] }) } } else { // The key doesn't exist in the en-US file but exists in current yaml file. // We should go through this eventually but it's not as important as invalid templates // errors.push({ fileName: file, error: 'extra key found', key: fdk }) } }) } /** * * @param {String} defaultValue * @param {String} otherValue */ function isMissingInterpolation(defaultValue, otherValue, filename) { if (otherValue === '') { // not translated yet, we don't care return false } const defaultMatches = Array.from(new Set(defaultValue.match(/{[^}]*}/g))) const otherMatches = Array.from(new Set(otherValue.match(/{[^}]*}/g))) if (defaultMatches) { if (!otherMatches) { // no templates found. return true } defaultMatches.sort() otherMatches.sort() const defaultMatchesStringified = JSON.stringify(defaultMatches) const otherMatchesStringified = JSON.stringify(otherMatches) // check if templates match. return defaultMatchesStringified !== otherMatchesStringified } else if (otherMatches) { // extra template found return true } } ================================================ FILE: _scripts/getInstances.js ================================================ const fs = require('fs/promises') const invidiousApiUrl = 'https://api.invidious.io/instances.json' fetch(invidiousApiUrl).then(e => e.json()).then(res => { const data = res.filter((instance) => { return !(instance[0].includes('.onion') || instance[0].includes('.i2p') || !instance[1].api) }).map((instance) => { return { url: instance[1].uri.replace(/\/$/, ''), cors: instance[1].cors } }) fs.writeFile('././static/invidious-instances.json', JSON.stringify(data, null, 2)) }) ================================================ FILE: _scripts/getRegions.mjs ================================================ /** * This script updates the files in static/geolocations with the available locations on YouTube. * * It tries to map every active FreeTube language (static/locales/activelocales.json) * to it's equivalent on YouTube. * * It then uses those language mappings, * to scrape the location selection menu on the YouTube website, in every mapped language. * * All languages it couldn't find on YouTube, that don't have manually added mapping, * get logged to the console, as well as all unmapped YouTube languages. */ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' import { Innertube, Misc } from 'youtubei.js' const STATIC_DIRECTORY = `${import.meta.dirname}/../static` const activeLanguagesPath = `${STATIC_DIRECTORY}/locales/activeLocales.json` /** @type {string[]} */ const activeLanguages = JSON.parse(readFileSync(activeLanguagesPath, { encoding: 'utf8' })) // en-US is en on YouTube const initialResponse = await scrapeLanguage('en') // Scrape language menu in en-US /** @type {string[]} */ const youTubeLanguages = initialResponse.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items[2].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items .map(({ compactLinkRenderer }) => { return compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].selectLanguageCommand.hl }) // map FreeTube languages to their YouTube equivalents const foundLanguageNames = ['en-US'] const unusedYouTubeLanguageNames = [] const languagesToScrape = [] for (const language of youTubeLanguages) { if (activeLanguages.includes(language)) { foundLanguageNames.push(language) languagesToScrape.push({ youTube: language, freeTube: language }) // eslint-disable-next-line @stylistic/brace-style } // special cases else if (language === 'de') { foundLanguageNames.push('de-DE') languagesToScrape.push({ youTube: 'de', freeTube: 'de-DE' }) } else if (language === 'fr') { foundLanguageNames.push('fr-FR') languagesToScrape.push({ youTube: 'fr', freeTube: 'fr-FR' }) } else if (language === 'no') { // according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes // "no" is the macro language for "nb" and "nn" foundLanguageNames.push('nb-NO', 'nn') languagesToScrape.push({ youTube: 'no', freeTube: 'nb-NO' }) languagesToScrape.push({ youTube: 'no', freeTube: 'nn' }) } else if (language === 'iw') { // according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes // "iw" is the old/original code for Hebrew, these days it's "he" foundLanguageNames.push('he') languagesToScrape.push({ youTube: 'iw', freeTube: 'he' }) } else if (language === 'es-419') { foundLanguageNames.push('es-AR', 'es-MX') languagesToScrape.push({ youTube: 'es-419', freeTube: 'es-AR' }) languagesToScrape.push({ youTube: 'es-419', freeTube: 'es-MX' }) } else if (language !== 'en') { unusedYouTubeLanguageNames.push(language) } } foundLanguageNames.push('pt-BR') languagesToScrape.push({ youTube: 'pt', freeTube: 'pt-BR' }) console.log("Active FreeTube languages that aren't available on YouTube:") console.log(activeLanguages.filter(lang => !foundLanguageNames.includes(lang)).sort()) console.log("YouTube languages that don't have an equivalent active FreeTube language:") console.log(unusedYouTubeLanguageNames.sort()) // Scrape the location menu in various languages and write files to the file system rmSync(`${STATIC_DIRECTORY}/geolocations`, { recursive: true }) mkdirSync(`${STATIC_DIRECTORY}/geolocations`) processGeolocations('en-US', 'en', initialResponse) for (const { youTube, freeTube } of languagesToScrape) { const response = await scrapeLanguage(youTube) processGeolocations(freeTube, youTube, response) } /** * @param {string} youTubeLanguageCode */ async function scrapeLanguage(youTubeLanguageCode) { const session = await Innertube.create({ retrieve_player: false, generate_session_locally: true, lang: youTubeLanguageCode }) return await session.actions.execute('/account/account_menu') } /** * @param {string} freeTubeLanguage * @param {string} youTubeLanguage * @param {import('youtubei.js').ApiResponse} response */ function processGeolocations(freeTubeLanguage, youTubeLanguage, response) { /** @type {{ name: string, code: string }[]} */ const geolocations = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items[4].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items .map(({ compactLinkRenderer }) => { return { name: new Misc.Text(compactLinkRenderer.title).toString().trim(), code: compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].selectCountryCommand.gl } }) const normalisedFreeTubeLanguage = freeTubeLanguage.replace('_', '-') // give Intl.Collator 4 locales, in the hopes that it supports one of them // deduplicate the list so it doesn't have to do duplicate work const localeSet = new Set() localeSet.add(normalisedFreeTubeLanguage) localeSet.add(youTubeLanguage) localeSet.add(normalisedFreeTubeLanguage.split('-')[0]) localeSet.add(youTubeLanguage.split('-')[0]) const locales = Array.from(localeSet) // only sort if node supports sorting the language, otherwise hope that YouTube's sorting was correct // node 20.3.1 doesn't support sorting `eu` if (Intl.Collator.supportedLocalesOf(locales).length > 0) { const collator = new Intl.Collator(locales) geolocations.sort((a, b) => collator.compare(a.name, b.name)) } const output = { names: geolocations.map(entry => entry.name), codes: geolocations.map(entry => entry.code) } writeFileSync(`${STATIC_DIRECTORY}/geolocations/${freeTubeLanguage}.json`, JSON.stringify(output)) } ================================================ FILE: _scripts/getShakaLocales.js ================================================ const { readFileSync, readdirSync } = require('fs') const { join } = require('path') function getPreloadedLocales() { const localesFile = readFileSync(join(__dirname, '../node_modules/shaka-player/dist/locales.js'), 'utf-8') const localesLine = localesFile.match(/^\/\/ LOCALES: ([\w ,-]+)$/m) if (!localesLine) { throw new Error("Failed to parse shaka-player's preloaded locales") } return localesLine[1].split(',').map(locale => locale.trim()) } function getAllLocales() { const filenames = readdirSync(join(__dirname, '../node_modules/shaka-player/ui/locales')) return new Set(filenames .filter(filename => filename !== 'source.json' && filename.endsWith('.json')) .map(filename => filename.replace('.json', ''))) } /** * Maps the shaka locales to FreeTube's active ones * This allows us to know which locale files are actually needed * and which shaka locale needs to be activated for a given FreeTube one. * @param {Set} shakaLocales * @param {string[]} freeTubeLocales */ function getMappings(shakaLocales, freeTubeLocales) { /** * @type {[string, string][]} * Using this structure as it gets passed to `new Map()` in the player component * The first element is the FreeTube locale, the second one is the shaka-player one */ const mappings = [] for (const locale of freeTubeLocales) { if (shakaLocales.has(locale)) { mappings.push([ locale, locale ]) } else if (shakaLocales.has(locale.split('-')[0])) { mappings.push([ locale, locale.split('-')[0] ]) } } // special cases mappings.push( // according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes // "no" is the macro language for "nb" and "nn" [ 'nb-NO', 'no' ], [ 'nn', 'no' ], // according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes // "iw" is the old/original code for Hebrew, these days it's "he" [ 'he', 'iw' ], // not sure why we have pt, pt-PT and pt-BR in the FreeTube locales // as pt and pt-PT are the same thing, but we should handle it here anyway [ 'pt', 'pt-PT' ] ) return mappings } function getShakaLocales() { const shakaLocales = getAllLocales() /** @type {string[]} */ const freeTubeLocales = JSON.parse(readFileSync(join(__dirname, '../static/locales/activeLocales.json'), 'utf-8')) const mappings = getMappings(shakaLocales, freeTubeLocales) const preloaded = getPreloadedLocales() const shakaMappings = mappings.map(mapping => mapping[1]) // use a set to deduplicate the list // we don't need to bundle any locale files that are already embedded in shaka-player/preloaded /** @type {string[]} */ const toBeBundled = [...new Set(shakaMappings.filter(locale => !preloaded.includes(locale)))] return { SHAKA_LOCALE_MAPPINGS: mappings, SHAKA_LOCALES_PREBUNDLED: preloaded, SHAKA_LOCALES_TO_BE_BUNDLED: toBeBundled } } module.exports = getShakaLocales() ================================================ FILE: _scripts/injectAllowedPaths.mjs ================================================ /** * Injects the paths that the renderer process is allowed to read into the main.js file, * by replacing __FREETUBE_ALLOWED_PATHS__ with an array of strings with the paths. * * This allows the main process to validate the paths which the renderer process accesses, * to ensure that it cannot access other files on the disk, without the users permission (e.g. file picker). */ import { closeSync, ftruncateSync, openSync, readFileSync, readdirSync, writeSync } from 'fs' import { join, relative, resolve } from 'path' const distDirectory = resolve(import.meta.dirname, '..', 'dist') const webDirectory = join(distDirectory, 'web') const paths = readdirSync(distDirectory, { recursive: true, withFileTypes: true }) .filter(dirent => { // only include files not directories return dirent.isFile() && // disallow the renderer process/browser windows to read the main.js file dirent.name !== 'main.js' && dirent.name !== 'main.js.LICENSE.txt' && // disallow the renderer process/browser windows to read the preload.js file dirent.name !== 'preload.js' && // disallow the renderer process/browser windows to read the botGuardScript.js file dirent.name !== 'botGuardScript.js' && // filter out any web build files, in case the dist directory contains a web build !dirent.parentPath.startsWith(webDirectory) }) .map(dirent => { const joined = join(dirent.parentPath, dirent.name) return '/' + relative(distDirectory, joined).replaceAll('\\', '/') }) let fileHandle try { fileHandle = openSync(join(distDirectory, 'main.js'), 'r+') let contents = readFileSync(fileHandle, 'utf-8') contents = contents.replace('__FREETUBE_ALLOWED_PATHS__', JSON.stringify(paths)) ftruncateSync(fileHandle) writeSync(fileHandle, contents, 0, 'utf-8') } finally { if (typeof fileHandle !== 'undefined') { closeSync(fileHandle) } } ================================================ FILE: _scripts/mime-db-shrinking-loader.js ================================================ /** * electron-context-menu only needs mime-db for its save as feature. * As we only activate save image and save as image features, we can remove all other mimetypes, * as they will never get used. * Which results in quite a significant reduction in file size. * @param {string} source */ module.exports = function (source) { const original = JSON.parse(source) // Only the extensions field is needed, see: https://github.com/kevva/ext-list/blob/v2.2.2/index.js return JSON.stringify({ 'image/apng': { extensions: original['image/apng'].extensions }, 'image/avif': { extensions: original['image/avif'].extensions }, 'image/gif': { extensions: original['image/gif'].extensions }, 'image/jpeg': { extensions: original['image/jpeg'].extensions }, 'image/png': { extensions: original['image/png'].extensions }, 'image/svg+xml': { extensions: original['image/svg+xml'].extensions }, 'image/webp': { extensions: original['image/webp'].extensions } }) } ================================================ FILE: _scripts/patch-shaka-player-loader.js ================================================ /** * fixes shaka-player referencing the Roboto font on google fonts in its CSS * by updating the CSS to point to the local Roboto font * @param {string} source */ module.exports = function (source) { return source.replace(/@font-face{font-family:Roboto;[^}]+}/, '') } ================================================ FILE: _scripts/sigFrameConfig.js ================================================ const { hash } = require('crypto') const { join } = require('path') const { readFileSync } = require('fs') const path = join(__dirname, '../src/renderer/sigFrameScript.js') const rawScript = readFileSync(path, 'utf8') const script = require('terser').minify_sync({ [path]: rawScript }).code module.exports.sigFrameTemplateParameters = { sigFrameSrc: `data:text/html,${encodeURIComponent(``)}`, sigFrameCspHash: `sha512-${hash('sha512', script, 'base64')}` } ================================================ FILE: _scripts/webpack.botGuardScript.config.js ================================================ const path = require('path') /** @type {import('webpack').Configuration} */ module.exports = { name: 'botGuardScript', // Always use production mode, as we use the output as a function body and the debug output doesn't work for that mode: 'production', devtool: false, target: 'web', entry: { botGuardScript: path.join(__dirname, '../src/botGuardScript.js'), }, output: { filename: '[name].js', path: path.join(__dirname, '../dist'), library: { type: 'modern-module' } }, experiments: { outputModule: true } } ================================================ FILE: _scripts/webpack.main.config.js ================================================ const path = require('path') const webpack = require('webpack') const CopyWebpackPlugin = require('copy-webpack-plugin') const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin') const isDevMode = process.env.NODE_ENV === 'development' /** @type {import('webpack').Configuration} */ const config = { name: 'main', mode: process.env.NODE_ENV, devtool: isDevMode ? 'eval-cheap-module-source-map' : false, entry: { main: path.join(__dirname, '../src/main/index.js'), }, module: { rules: [ { test: /\.js$/, use: 'babel-loader', exclude: /node_modules/, }, { resource: path.resolve(__dirname, '../node_modules/mime-db/db.json'), use: path.join(__dirname, 'mime-db-shrinking-loader.js') } ], generator: { json: { JSONParse: false } } }, // webpack defaults to only optimising the production builds, so having this here is fine optimization: { minimizer: [ '...', // extend webpack's list instead of overwriting it new JsonMinimizerPlugin({ exclude: /\/locales\/.*\.json/ }) ] }, node: { __dirname: isDevMode, __filename: isDevMode }, plugins: [ new webpack.DefinePlugin({ 'process.platform': `'${process.platform}'`, 'process.env.IS_ELECTRON_MAIN': true }) ], output: { filename: '[name].js', libraryTarget: 'commonjs2', path: path.join(__dirname, '../dist'), }, target: 'electron-main', } if (!isDevMode) { config.plugins.push( new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, '../static'), to: path.join(__dirname, '../dist/static'), globOptions: { dot: true, ignore: ['**/.*', '**/locales/**', '**/pwabuilder-sw.js', '**/manifest.json', '**/dashFiles/**', '**/storyboards/**'], }, }, ] }) ) } module.exports = config ================================================ FILE: _scripts/webpack.preload.config.js ================================================ const path = require('path') const isDevMode = process.env.NODE_ENV === 'development' /** @type {import('webpack').Configuration} */ const config = { name: 'preload', mode: process.env.NODE_ENV, devtool: isDevMode ? 'eval-cheap-module-source-map' : false, entry: { preload: path.join(__dirname, '../src/preload/main.js'), }, infrastructureLogging: { // Only warnings and errors // level: 'none' disable logging // Please read https://webpack.js.org/configuration/other-options/#infrastructurelogginglevel level: isDevMode ? 'info' : 'none' }, output: { path: path.join(__dirname, '../dist'), filename: '[name].js' }, externals: [ 'electron/renderer' ], externalsType: 'commonjs', node: { __dirname: false, __filename: false }, target: 'electron-preload', } module.exports = config ================================================ FILE: _scripts/webpack.renderer.config.js ================================================ const path = require('path') const { readFileSync, readdirSync } = require('fs') const webpack = require('webpack') const HtmlWebpackPlugin = require('html-webpack-plugin') const { VueLoaderPlugin } = require('vue-loader') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') const CopyWebpackPlugin = require('copy-webpack-plugin') const { SHAKA_LOCALE_MAPPINGS, SHAKA_LOCALES_PREBUNDLED, SHAKA_LOCALES_TO_BE_BUNDLED } = require('./getShakaLocales') const { sigFrameTemplateParameters } = require('./sigFrameConfig') const isDevMode = process.env.NODE_ENV === 'development' const { version: swiperVersion } = JSON.parse(readFileSync(path.join(__dirname, '../node_modules/swiper/package.json'))) const processLocalesPlugin = new ProcessLocalesPlugin({ compress: !isDevMode, hotReload: isDevMode, inputDir: path.join(__dirname, '../static/locales'), outputDir: 'static/locales', }) /** @type {import('webpack').Configuration} */ const config = { name: 'renderer', mode: process.env.NODE_ENV, devtool: isDevMode ? 'eval-cheap-module-source-map' : false, entry: { renderer: path.join(__dirname, '../src/renderer/main.js'), }, infrastructureLogging: { // Only warnings and errors // level: 'none' disable logging // Please read https://webpack.js.org/configuration/other-options/#infrastructurelogginglevel level: isDevMode ? 'info' : 'none' }, output: { scriptType: 'text/javascript', path: path.join(__dirname, '../dist'), filename: '[name].js', }, module: { rules: [ { test: /\.js$/, use: 'babel-loader', exclude: /node_modules/, }, { test: /\.vue$/, loader: 'vue-loader', options: { compilerOptions: { isCustomElement: (tag) => tag === 'swiper-container' || tag === 'swiper-slide' } } }, { test: /\.scss$/, use: [ { loader: MiniCssExtractPlugin.loader, }, { loader: 'css-loader', options: { esModule: false } }, { loader: 'sass-loader', options: { implementation: require('sass') } }, ], }, { test: /\.css$/, use: [ { loader: MiniCssExtractPlugin.loader }, { loader: 'css-loader', options: { esModule: false } } ], rules: [ { resource: path.resolve(__dirname, '../node_modules/shaka-player/dist/controls.css'), use: path.join(__dirname, 'patch-shaka-player-loader.js') } ], }, { test: /\.(png|jpe?g|gif|tif?f|bmp|webp|svg)(\?.*)?$/, type: 'asset/resource', generator: { filename: 'imgs/[name][ext]' } }, { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, type: 'asset/resource', generator: { filename: 'fonts/[name][ext]' } }, ], generator: { json: { JSONParse: false } } }, // webpack defaults to only optimising the production builds, so having this here is fine optimization: { minimizer: [ '...', // extend webpack's list instead of overwriting it new CssMinimizerPlugin() ] }, node: { __dirname: false, __filename: false }, plugins: [ processLocalesPlugin, new webpack.DefinePlugin({ 'process.platform': `'${process.platform}'`, 'process.env.IS_ELECTRON': true, 'process.env.IS_ELECTRON_MAIN': false, 'process.env.SUPPORTS_LOCAL_API': true, __VUE_OPTIONS_API__: 'true', __VUE_PROD_DEVTOOLS__: 'false', __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', __VUE_I18N_LEGACY_API__: 'true', __VUE_I18N_FULL_INSTALL__: 'false', __INTLIFY_PROD_DEVTOOLS__: 'false', 'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames), 'process.env.GEOLOCATION_NAMES': JSON.stringify(readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))), 'process.env.SWIPER_VERSION': `'${swiperVersion}'`, 'process.env.SHAKA_LOCALE_MAPPINGS': JSON.stringify(SHAKA_LOCALE_MAPPINGS), 'process.env.SHAKA_LOCALES_PREBUNDLED': JSON.stringify(SHAKA_LOCALES_PREBUNDLED) }), new HtmlWebpackPlugin({ filename: 'index.html', template: path.resolve(__dirname, '../src/index.ejs'), templateParameters: sigFrameTemplateParameters }), new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: isDevMode ? '[name].css' : '[name].[contenthash].css', chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css', }), new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'), to: `swiper-${swiperVersion}.css`, context: path.join(__dirname, '../node_modules/swiper/modules'), transformAll: (assets) => { return Buffer.concat(assets.map(asset => asset.data)) } }, // Don't need to copy them in dev mode, // as we configure WebpackDevServer to serve them ...(isDevMode ? [] : [ { from: path.join(__dirname, '../node_modules/shaka-player/ui/locales', `{${SHAKA_LOCALES_TO_BE_BUNDLED.join(',')}}.json`).replaceAll('\\', '/'), to: path.join(__dirname, '../dist/static/shaka-player-locales'), context: path.join(__dirname, '../node_modules/shaka-player/ui/locales'), transform: { transformer: (input) => { return JSON.stringify(JSON.parse(input.toString('utf-8'))) } } } ]) ] }) ], resolve: { alias: { DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$: path.resolve(__dirname, '../src/datastores/handlers/electron.js'), 'youtubei.js$': 'youtubei.js/web', // change to "shaka-player.ui-es2021.debug.js" to get debug logs (update jsconfig to get updated types) 'shaka-player$': 'shaka-player/dist/shaka-player.ui-es2021.js', // Make @fortawesome/vue-fontawesome use the trimmed down API instead of the original @fortawesome/fontawesome-svg-core '@fortawesome/fontawesome-svg-core$': path.resolve(__dirname, '../src/renderer/fontawesome-minimal.js'), // Fix dompurify not being tree-shaking friendly dompurify$: path.resolve(__dirname, '_undefinedDefaultExport.mjs') }, extensions: ['.js', '.vue'] }, target: 'web', } if (isDevMode) { // hack to pass it through to the dev-runner.js script // gets removed there before the config object is passed to webpack config.SHAKA_LOCALES_TO_BE_BUNDLED = SHAKA_LOCALES_TO_BE_BUNDLED } module.exports = config ================================================ FILE: _scripts/webpack.web.config.js ================================================ const path = require('path') const fs = require('fs') const webpack = require('webpack') const HtmlWebpackPlugin = require('html-webpack-plugin') const { VueLoaderPlugin } = require('vue-loader') const CopyWebpackPlugin = require('copy-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') const { SHAKA_LOCALE_MAPPINGS, SHAKA_LOCALES_PREBUNDLED, SHAKA_LOCALES_TO_BE_BUNDLED } = require('./getShakaLocales') const isDevMode = process.env.NODE_ENV === 'development' const { version: swiperVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '../node_modules/swiper/package.json'))) /** @type {import('webpack').Configuration} */ const config = { name: 'web', mode: process.env.NODE_ENV, devtool: isDevMode ? 'eval-cheap-module-source-map' : false, entry: { web: path.join(__dirname, '../src/renderer/main.js'), }, output: { path: path.join(__dirname, '../dist/web'), filename: '[name].js', }, externals: { 'youtubei.js': '{}', googlevideo: '{}' }, module: { rules: [ { test: /\.js$/, use: 'babel-loader', exclude: /node_modules/, }, { test: /\.vue$/, loader: 'vue-loader', options: { compilerOptions: { isCustomElement: (tag) => tag === 'swiper-container' || tag === 'swiper-slide', } } }, { test: /\.scss$/, use: [ { loader: MiniCssExtractPlugin.loader, }, { loader: 'css-loader', options: { esModule: false } }, { loader: 'sass-loader', options: { implementation: require('sass') } }, ], }, { test: /\.css$/, use: [ { loader: MiniCssExtractPlugin.loader }, { loader: 'css-loader', options: { esModule: false } } ], rules: [ { resource: path.resolve(__dirname, '../node_modules/shaka-player/dist/controls.css'), use: path.join(__dirname, 'patch-shaka-player-loader.js') } ], }, { test: /\.html$/, use: 'vue-html-loader', }, { test: /\.(png|jpe?g|gif|tif?f|bmp|webp|svg)(\?.*)?$/, type: 'asset/resource', generator: { filename: 'imgs/[name][ext]' } }, { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, type: 'asset/resource', generator: { filename: 'fonts/[name][ext]' } }, ], generator: { json: { JSONParse: false } } }, // webpack defaults to only optimising the production builds, so having this here is fine optimization: { minimizer: [ '...', // extend webpack's list instead of overwriting it new JsonMinimizerPlugin({ exclude: /\/locales\/.*\.json/ }), new CssMinimizerPlugin() ] }, node: { __dirname: false, __filename: false }, plugins: [ new webpack.DefinePlugin({ 'process.platform': 'undefined', 'process.env.IS_ELECTRON': false, 'process.env.IS_ELECTRON_MAIN': false, 'process.env.SUPPORTS_LOCAL_API': false, __VUE_OPTIONS_API__: 'true', __VUE_PROD_DEVTOOLS__: 'false', __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', __VUE_I18N_LEGACY_API__: 'true', __VUE_I18N_FULL_INSTALL__: 'false', __INTLIFY_PROD_DEVTOOLS__: 'false', 'process.env.SWIPER_VERSION': `'${swiperVersion}'` }), new webpack.ProvidePlugin({ process: 'process/browser.js' }), new HtmlWebpackPlugin({ excludeChunks: ['processTaskWorker'], filename: 'index.html', template: path.resolve(__dirname, '../src/index.ejs') }), new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: isDevMode ? '[name].css' : '[name].[contenthash].css', chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css', }), new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'), to: `swiper-${swiperVersion}.css`, context: path.join(__dirname, '../node_modules/swiper/modules'), transformAll: (assets) => { return Buffer.concat(assets.map(asset => asset.data)) } } ] }) ], resolve: { alias: { DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$: path.resolve(__dirname, '../src/datastores/handlers/web.js'), // change to "shaka-player.ui-es2021.debug.js" to get debug logs (update jsconfig to get updated types) 'shaka-player$': 'shaka-player/dist/shaka-player.ui-es2021.js', // Make @fortawesome/vue-fontawesome use the trimmed down API instead of the original @fortawesome/fontawesome-svg-core '@fortawesome/fontawesome-svg-core$': path.resolve(__dirname, '../src/renderer/fontawesome-minimal.js') }, extensions: ['.js', '.vue'] }, target: 'web', } const processLocalesPlugin = new ProcessLocalesPlugin({ compress: false, hotReload: isDevMode, inputDir: path.join(__dirname, '../static/locales'), outputDir: 'static/locales', }) config.plugins.push( processLocalesPlugin, new webpack.DefinePlugin({ 'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames), 'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))), 'process.env.SHAKA_LOCALE_MAPPINGS': JSON.stringify(SHAKA_LOCALE_MAPPINGS), 'process.env.SHAKA_LOCALES_PREBUNDLED': JSON.stringify(SHAKA_LOCALES_PREBUNDLED) }), new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, '../static/pwabuilder-sw.js'), to: path.join(__dirname, '../dist/web/pwabuilder-sw.js'), }, { from: path.join(__dirname, '../static'), to: path.join(__dirname, '../dist/web/static'), globOptions: { dot: true, ignore: ['**/.*', '**/locales/**', '**/pwabuilder-sw.js', '**/dashFiles/**', '**/storyboards/**'], }, }, { from: path.join(__dirname, '../node_modules/shaka-player/ui/locales', `{${SHAKA_LOCALES_TO_BE_BUNDLED.join(',')}}.json`).replaceAll('\\', '/'), to: path.join(__dirname, '../dist/web/static/shaka-player-locales'), context: path.join(__dirname, '../node_modules/shaka-player/ui/locales') } ] }) ) module.exports = config ================================================ FILE: eslint.config.mjs ================================================ import eslintPluginVue from 'eslint-plugin-vue' import vuejsAccessibility from 'eslint-plugin-vuejs-accessibility' import eslintPluginUnicorn from 'eslint-plugin-unicorn' import intlifyVueI18N from '@intlify/eslint-plugin-vue-i18n' import globals from 'globals' import vueEslintParser from 'vue-eslint-parser' import js from '@eslint/js' import eslintPluginJsonc from 'eslint-plugin-jsonc' import eslintPluginYml from 'eslint-plugin-yml' import jsdoc from 'eslint-plugin-jsdoc' import stylistic from '@stylistic/eslint-plugin' import eslintPluginImportX from 'eslint-plugin-import-x' import eslintPluginN from 'eslint-plugin-n' import eslintPluginPromise from 'eslint-plugin-promise' import freetube from './_scripts/eslint-rules/plugin.mjs' import activeLocales from './static/locales/activeLocales.json' with { type: 'json' } export default [ { ignores: [ 'build/', 'dist/', 'eslint.config.mjs', // The JSON files inside this directory are auto-generated, so they don't follow the code style rules 'static/geolocations/' ] }, { name: 'base', languageOptions: { ecmaVersion: 2022, sourceType: 'module', globals: { ...globals.es2022, ...globals.node, document: 'readonly', navigator: 'readonly', window: 'readonly', }, }, plugins: { 'import-x': eslintPluginImportX, n: eslintPluginN, promise: eslintPluginPromise, }, rules: { 'no-var': 'warn', 'object-shorthand': ['warn', 'properties'], 'accessor-pairs': ['error', { setWithoutGet: true, enforceForClassMembers: true }], 'array-callback-return': ['error', { allowImplicit: false, checkForEach: false, }], camelcase: ['error', { allow: ['^UNSAFE_'], properties: 'never', ignoreGlobals: true, }], curly: ['error', 'multi-line'], 'default-case-last': 'error', eqeqeq: ['error', 'always', { null: 'ignore' }], 'new-cap': ['error', { newIsCap: true, capIsNew: false, properties: true }], 'no-array-constructor': 'error', 'no-caller': 'error', 'no-constant-condition': ['error', { checkLoops: false }], 'no-empty': ['error', { allowEmptyCatch: true }], 'no-eval': 'error', 'no-extend-native': 'error', 'no-extra-bind': 'error', 'no-implied-eval': 'error', 'no-iterator': 'error', 'no-labels': ['error', { allowLoop: false, allowSwitch: false }], 'no-lone-blocks': 'error', 'no-multi-str': 'error', 'no-new': 'error', 'no-new-func': 'error', 'no-object-constructor': 'error', 'no-new-wrappers': 'error', 'no-octal-escape': 'error', 'no-proto': 'error', 'no-redeclare': ['error', { builtinGlobals: false }], 'no-return-assign': ['error', 'except-parens'], 'no-self-compare': 'error', 'no-sequences': 'error', 'no-template-curly-in-string': 'error', 'no-throw-literal': 'error', 'no-undef-init': 'error', 'no-unmodified-loop-condition': 'error', 'no-unneeded-ternary': ['error', { defaultAssignment: false }], 'no-unreachable-loop': 'error', 'no-unused-expressions': ['error', { allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true, }], 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none', ignoreRestSiblings: true, vars: 'all', }], 'no-use-before-define': ['error', { functions: false, classes: false, variables: false }], 'no-useless-call': 'error', 'no-useless-computed-key': 'error', 'no-useless-constructor': 'error', 'no-useless-rename': 'error', 'no-useless-return': 'error', 'no-void': 'error', 'one-var': ['error', { initialized: 'never' }], 'prefer-const': ['error', { destructuring: 'all' }], 'prefer-promise-reject-errors': 'error', 'prefer-regex-literals': ['error', { disallowRedundantWrapping: true }], 'symbol-description': 'error', 'unicode-bom': ['error', 'never'], 'use-isnan': ['error', { enforceForSwitchCase: true, enforceForIndexOf: true, }], 'valid-typeof': ['error', { requireStringLiterals: true }], yoda: ['error', 'never'], 'import-x/export': 'error', 'import-x/first': 'error', 'import-x/no-absolute-path': ['error', { esmodule: true, commonjs: true, amd: false }], 'import-x/no-duplicates': 'error', 'import-x/no-named-default': 'error', 'import-x/no-webpack-loader-syntax': 'error', 'n/handle-callback-err': ['error', '^(err|error)$'], 'n/no-callback-literal': 'error', 'n/no-deprecated-api': 'warn', 'n/no-exports-assign': 'error', 'n/no-new-require': 'error', 'n/no-path-concat': 'error', 'n/process-exit-as-throw': 'error', 'promise/param-names': 'error', }, }, { name: 'style', plugins: { '@stylistic': stylistic, }, rules: { '@stylistic/array-bracket-spacing': ['error', 'never'], '@stylistic/arrow-spacing': ['error', { before: true, after: true }], '@stylistic/block-spacing': ['error', 'always'], '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }], '@stylistic/comma-dangle': ['warn', { arrays: 'ignore', enums: 'ignore', exports: 'ignore', imports: 'ignore', objects: 'ignore', }], '@stylistic/comma-spacing': ['error', { before: false, after: true }], '@stylistic/comma-style': ['error', 'last'], '@stylistic/computed-property-spacing': ['error', 'never', { enforceForClassMembers: true }], '@stylistic/dot-location': ['error', 'property'], '@stylistic/eol-last': 'error', '@stylistic/function-call-spacing': ['error', 'never'], '@stylistic/generator-star-spacing': ['error', { before: true, after: true }], '@stylistic/indent': ['error', 2, { SwitchCase: 1, VariableDeclarator: 1, outerIIFEBody: 1, MemberExpression: 1, FunctionDeclaration: { parameters: 1, body: 1 }, FunctionExpression: { parameters: 1, body: 1 }, CallExpression: { arguments: 1 }, ArrayExpression: 1, ObjectExpression: 1, ImportDeclaration: 1, flatTernaryExpressions: false, ignoreComments: false, ignoredNodes: ['TemplateLiteral *'], offsetTernaryExpressions: true, }], '@stylistic/key-spacing': ['error', { beforeColon: false, afterColon: true }], '@stylistic/keyword-spacing': ['error', { before: true, after: true }], '@stylistic/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], '@stylistic/multiline-ternary': ['error', 'always-multiline'], '@stylistic/new-parens': 'error', '@stylistic/no-extra-parens': ['error', 'functions'], '@stylistic/no-floating-decimal': 'error', '@stylistic/no-mixed-operators': ['error', { groups: [ ['==', '!=', '===', '!==', '>', '>=', '<', '<='], ['&&', '||'], ['in', 'instanceof'], ], allowSamePrecedence: true, }], '@stylistic/no-mixed-spaces-and-tabs': 'error', '@stylistic/no-multi-spaces': ['error', { ignoreEOLComments: true }], '@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 0 }], '@stylistic/no-tabs': 'error', '@stylistic/no-trailing-spaces': 'error', '@stylistic/no-whitespace-before-property': 'error', '@stylistic/object-curly-newline': ['error', { multiline: true, consistent: true }], '@stylistic/object-curly-spacing': ['error', 'always'], '@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }], '@stylistic/operator-linebreak': ['error', 'after', { overrides: { '?': 'before', ':': 'before', '|>': 'before' } }], '@stylistic/padded-blocks': ['error', { blocks: 'never', switches: 'never', classes: 'never' }], '@stylistic/quote-props': ['error', 'as-needed'], '@stylistic/quotes': ['error', 'single', { avoidEscape: true, allowTemplateLiterals: 'never' }], '@stylistic/rest-spread-spacing': ['error', 'never'], '@stylistic/semi': ['error', 'never'], '@stylistic/semi-spacing': ['error', { before: false, after: true }], '@stylistic/space-before-blocks': ['error', 'always'], '@stylistic/space-before-function-paren': ['error', 'always'], '@stylistic/space-in-parens': ['error', 'never'], '@stylistic/space-infix-ops': 'error', '@stylistic/space-unary-ops': ['error', { words: true, nonwords: false }], '@stylistic/spaced-comment': ['error', 'always', { line: { markers: ['*package', '!', '/', ',', '='] }, block: { balanced: true, markers: ['*package', '!', ',', ':', '::', 'flow-include'], exceptions: ['*'] }, }], '@stylistic/template-curly-spacing': ['error', 'never'], '@stylistic/template-tag-spacing': ['error', 'never'], '@stylistic/wrap-iife': ['error', 'any', { functionPrototypeMethods: true }], '@stylistic/yield-star-spacing': ['error', 'both'], }, }, js.configs.recommended, ...eslintPluginVue.configs['flat/recommended'], ...vuejsAccessibility.configs["flat/recommended"], ...intlifyVueI18N.configs.recommended, { files: [ '**/*.{js,vue}', ], ignores: [ '_scripts/', ], plugins: { unicorn: eslintPluginUnicorn, jsdoc, freetube, }, languageOptions: { globals: { ...globals.browser, ...globals.node, }, parser: vueEslintParser, ecmaVersion: 2022, sourceType: 'module', }, settings: { 'vue-i18n': { localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, messageSyntaxVersion: '^11.0.0', }, }, rules: { '@stylistic/space-before-function-paren': 'off', '@stylistic/comma-dangle': ['error', 'only-multiline'], // Ban v-html as it inserts HTML via innerHTML without sanitizing it // if inserting raw HTML is unavoidable the custom v-safer-html directive should be used // which sanitizes the HTML before inserting it into the DOM 'vue/no-v-html': 'error', 'no-console': ['error', { allow: ['warn', 'error'], }], 'no-unused-vars': 'warn', 'no-undef': 'warn', 'object-shorthand': 'off', 'vue/multi-word-component-names': 'off', 'vuejs-accessibility/label-has-for': ['error', { required: { some: ['nesting', 'id'], }, }], 'vuejs-accessibility/no-static-element-interactions': 'off', 'unicorn/better-regex': 'error', 'unicorn/prefer-single-call': 'error', 'unicorn/prefer-keyboard-event-key': 'error', 'unicorn/prefer-regexp-test': 'error', 'unicorn/prefer-string-replace-all': 'error', 'unicorn/prefer-optional-catch-binding': 'error', 'unicorn/prefer-date-now': 'error', 'unicorn/prefer-array-index-of': 'error', '@intlify/vue-i18n/no-dynamic-keys': 'error', '@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error', '@intlify/vue-i18n/no-raw-text': ['error', { attributes: { '/.+/': [ 'title', 'aria-label', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext', 'tooltip', 'message', ], input: ['placeholder', 'value'], img: ['alt'], }, ignoreText: ['-', '•', '/', 'YouTube', 'Invidious', 'FreeTube'], }], 'vue/no-unused-emit-declarations': 'error', 'vue/prefer-use-template-ref': 'error', 'jsdoc/check-alignment': 'error', 'jsdoc/check-property-names': 'error', 'jsdoc/check-param-names': 'error', 'jsdoc/check-syntax': 'error', 'jsdoc/check-template-names': 'error', 'jsdoc/check-types': 'error', 'jsdoc/no-bad-blocks': 'error', 'jsdoc/no-multi-asterisks': 'error', 'freetube/prefer-use-i18n-polyfill': 'error', }, }, { files: ['src/main/index.js'], languageOptions: { globals: { __FREETUBE_ALLOWED_PATHS__: 'readable' } } }, { files: ['src/renderer/directives/vSaferHtml.js'], languageOptions: { globals: { // Fix Sanitizer not being listed in `globals` yet, remove it when it gets added in the future Sanitizer: 'readable' } } }, ...eslintPluginJsonc.configs.base, { files: ['**/*.json'], ignores: [ '_scripts/', ], rules: { '@stylistic/no-tabs': 'off', '@stylistic/comma-spacing': 'off', '@stylistic/eol-last': 'off', 'no-irregular-whitespace': 'off', }, settings: { 'vue-i18n': { localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, messageSyntaxVersion: '^11.0.0', }, }, }, ...eslintPluginYml.configs.recommended, { files: ['**/*.{yml,yaml}'], ignores: [ '.github/', '_scripts/' ], rules: { 'yml/no-irregular-whitespace': 'off', '@stylistic/spaced-comment': 'off', }, settings: { 'vue-i18n': { localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, messageSyntaxVersion: '^11.0.0', }, }, }, { files: ['.github/**/*.{yml,yaml}'], rules: { 'yml/no-empty-mapping-value': 'off', 'yml/no-irregular-whitespace': 'off', }, settings: { 'vue-i18n': { localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, messageSyntaxVersion: '^11.0.0', }, }, }, { files: ['_scripts/*.js'], languageOptions: { globals: globals.node, ecmaVersion: 'latest', sourceType: 'commonjs' }, plugins: { unicorn: eslintPluginUnicorn, }, rules: { '@stylistic/space-before-function-paren': 'off', '@stylistic/comma-dangle': ['error', 'only-multiline'], 'no-console': 'off', 'unicorn/better-regex': 'error', 'unicorn/prefer-optional-catch-binding': 'error', 'unicorn/prefer-date-now': 'error', 'unicorn/prefer-array-index-of': 'error', } }, { files: ['_scripts/**/*.mjs'], languageOptions: { globals: globals.node, ecmaVersion: 'latest', sourceType: 'module', }, plugins: { unicorn: eslintPluginUnicorn, }, rules: { 'no-console': 'off', '@stylistic/space-before-function-paren': 'off', '@stylistic/comma-dangle': ['error', 'only-multiline'], 'unicorn/better-regex': 'error', 'unicorn/prefer-optional-catch-binding': 'error', 'unicorn/prefer-date-now': 'error', 'unicorn/prefer-array-index-of': 'error', } } ] ================================================ FILE: jsconfig.json ================================================ { "vueCompilerOptions": { "target": 3.5 }, "compilerOptions": { "module": "esnext", "moduleResolution": "bundler", "strictNullChecks": true, "baseUrl": "./", "paths": { "DB_HANDLERS_ELECTRON_RENDERER_OR_WEB": [ "src/datastores/handlers/electron", "src/datastores/handlers/web" ], "shaka-player": [ "./node_modules/shaka-player/dist/shaka-player.ui-es2021" ] } } } ================================================ FILE: lefthook-local.yml.example ================================================ # See following doc for details # https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md#rc # You can choose whatever name/path you want for `~/.lefthookrc`. # You can share it between projects where you use lefthook. # Make sure the path is absolute. rc: ~/.lefthookrc ================================================ FILE: lefthook.yml ================================================ # Refer for explanation to following link: # https://github.com/evilmartians/lefthook/blob/master/docs/full_guide.md pre-commit: parallel: true commands: eslint: # Only runs when any file with filename # matching the glob is being committed glob: "*.{js,vue}" run: yarn run eslint --no-color {staged_files} skip: - rebase stylelint: glob: "*.{css,scss}" run: yarn stylelint --no-color --allow-empty-input {staged_files} skip: - rebase # EXAMPLE USAGE # # pre-push: # commands: # packages-audit: # tags: frontend security # run: yarn audit # gems-audit: # tags: backend security # run: bundle audit # # pre-commit: # parallel: true # commands: # eslint: # glob: "*.{js,ts}" # run: yarn eslint {staged_files} # rubocop: # tags: backend style # glob: "*.rb" # exclude: "application.rb|routes.rb" # run: bundle exec rubocop --force-exclusion {all_files} # govet: # tags: backend style # files: git ls-files -m # glob: "*.go" # run: go vet {files} # scripts: # "hello.js": # runner: node # "any.go": # runner: go run ================================================ FILE: package.json ================================================ { "name": "freetube", "productName": "FreeTube", "description": "A private YouTube client", "version": "0.23.15", "license": "AGPL-3.0-or-later", "main": "./dist/main.js", "private": true, "author": { "name": "FreeTube Team", "email": "FreeTubeApp@protonmail.com", "url": "https://github.com/FreeTubeApp/FreeTube" }, "repository": { "type": "git", "url": "git+https://github.com/FreeTubeApp/FreeTube.git" }, "bugs": { "url": "https://github.com/FreeTubeApp/FreeTube/issues" }, "scripts": { "build": "run-s pack build-release", "build:arm64": "run-s pack build-release:arm64", "build:arm32": "run-s pack build-release:arm32", "build-release": "node _scripts/build.mjs", "build-release:arm64": "node _scripts/build.mjs arm64", "build-release:arm32": "node _scripts/build.mjs arm32", "clean": "node _scripts/clean.mjs", "debug": "node _scripts/dev-runner.js --remote-debug", "dev": "node _scripts/dev-runner.js", "dev:web": "node _scripts/dev-runner.js --web", "get-instances": "node _scripts/getInstances.js", "get-regions": "node _scripts/getRegions.mjs", "lint-all": "run-p lint lint-json", "lint": "run-p eslint-lint lint-style", "lint-fix": "run-p eslint-lint-fix lint-style-fix", "eslint-lint": "eslint --config eslint.config.mjs \"src/**/*.js\" \"src/renderer/**/*.vue\" \"static/*.js\" \"_scripts/*.js\" \"_scripts/**/*.mjs\"", "eslint-lint-fix": "eslint --config eslint.config.mjs --fix \"src/**/*.js\" \"src/renderer/**/*.vue\" \"static/*.js\" \"_scripts/*.js\" \"_scripts/**/*.mjs\"", "lint-json": "eslint --config eslint.config.mjs \"static/**/*.json\"", "lint-style": "stylelint \"src/**/*.{css,scss}\"", "lint-style-fix": "stylelint --fix \"src/**/*.{css,scss}\"", "lint-yml": "eslint --config eslint.config.mjs \"**/*.yml\" \"**/*.yaml\"", "pack": "run-p pack:main pack:renderer pack:preload pack:botGuardScript && node _scripts/injectAllowedPaths.mjs", "pack:main": "webpack --mode=production --config-node-env=production --config _scripts/webpack.main.config.js", "pack:renderer": "webpack --mode=production --config-node-env=production --config _scripts/webpack.renderer.config.js", "pack:preload": "webpack --mode=production --config-node-env=production --config _scripts/webpack.preload.config.js", "pack:web": "webpack --mode=production --config-node-env=production --config _scripts/webpack.web.config.js", "pack:botGuardScript": "webpack --config _scripts/webpack.botGuardScript.config.js", "checkforbadtemplates": "node _scripts/findMissingTemplates.mjs", "ci": "yarn install --silent --frozen-lockfile --network-concurrency 1" }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-brands-svg-icons": "^7.2.0", "@fortawesome/free-regular-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/vue-fontawesome": "^3.1.3", "@seald-io/nedb": "^4.1.2", "autolinker": "^4.1.5", "bgutils-js": "^3.2.0", "dompurify": "^3.3.3", "electron-context-menu": "^4.1.1", "googlevideo": "^4.0.4", "marked": "^17.0.4", "process": "^0.11.10", "shaka-player": "^5.0.6", "swiper": "^12.1.2", "vue": "^3.5.30", "vue-i18n": "^11.3.0", "vue-observe-visibility": "^2.0.0-alpha.1", "vue-router": "^5.0.3", "vuex": "^4.1.0", "youtubei.js": "^17.0.1" }, "devDependencies": { "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.0", "@double-great/stylelint-a11y": "^3.4.6", "@eslint/js": "^10.0.1", "@intlify/eslint-plugin-vue-i18n": "^4.3.0", "@stylistic/eslint-plugin": "^5.10.0", "babel-loader": "^10.1.1", "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.4", "css-minimizer-webpack-plugin": "^8.0.0", "electron": "^41.0.2", "electron-builder": "^26.8.1", "eslint": "^10.0.3", "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-jsdoc": "^62.8.0", "eslint-plugin-jsonc": "^3.1.2", "eslint-plugin-n": "^17.24.0", "eslint-plugin-promise": "^7.2.1", "eslint-plugin-unicorn": "^63.0.0", "eslint-plugin-vue": "^10.8.0", "eslint-plugin-vuejs-accessibility": "^2.5.0", "eslint-plugin-yml": "^3.3.1", "globals": "^17.4.0", "html-webpack-plugin": "^5.6.6", "js-yaml": "^4.1.1", "json-minimizer-webpack-plugin": "^5.0.1", "lefthook": "^2.1.4", "mini-css-extract-plugin": "^2.10.1", "npm-run-all2": "^8.0.4", "postcss": "^8.5.8", "postcss-scss": "^4.0.9", "sass": "^1.98.0", "sass-loader": "^16.0.7", "stylelint": "^17.4.0", "stylelint-config-sass-guidelines": "^13.0.0", "stylelint-config-standard": "^40.0.0", "stylelint-high-performance-animation": "^2.0.0", "stylelint-use-logical-spec": "^5.0.1", "tree-kill": "1.2.2", "vue-eslint-parser": "^10.2.0", "vue-loader": "^17.4.2", "webpack": "^5.105.4", "webpack-cli": "^7.0.1", "webpack-dev-server": "^5.2.3" } } ================================================ FILE: src/botGuardScript.js ================================================ import { BG, buildURL, GOOG_API_KEY } from 'bgutils-js' // This script has it's own webpack config, as it gets passed as a string to Electron's evaluateJavaScript function // in src/main/poTokenGenerator.js /** * Based on: https://github.com/LuanRT/BgUtils/blob/main/examples/node/innertube-challenge-fetcher-example.ts * @param {string} videoId * @param {import('youtubei.js').Session['context']} context */ export default async function (videoId, context) { const requestKey = 'O43z0dpjhgX20SCx4KAo' const challengeResponse = await fetch( 'https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false&alt=json', { method: 'POST', headers: { Accept: '*/*', 'Content-Type': 'application/json', 'X-Goog-Visitor-Id': context.client.visitorData, 'X-Youtube-Client-Version': context.client.clientVersion, 'X-Youtube-Client-Name': '1' }, body: JSON.stringify({ engagementType: 'ENGAGEMENT_TYPE_UNBOUND', context }), } ) if (!challengeResponse.ok) { throw new Error(`Request to ${challengeResponse.url} failed with status ${challengeResponse.status}\n${await challengeResponse.text()}`) } const challengeData = await challengeResponse.json() if (!challengeData.bgChallenge) { throw new Error('Failed to get BotGuard challenge') } let interpreterUrl = challengeData.bgChallenge.interpreterUrl.privateDoNotAccessOrElseTrustedResourceUrlWrappedValue if (interpreterUrl.startsWith('//')) { interpreterUrl = `https:${interpreterUrl}` } const bgScriptResponse = await fetch(interpreterUrl) const interpreterJavascript = await bgScriptResponse.text() if (interpreterJavascript) { // eslint-disable-next-line no-new-func new Function(interpreterJavascript)() } else { throw new Error('Could not load VM.') } const botGuard = await BG.BotGuardClient.create({ program: challengeData.bgChallenge.program, globalName: challengeData.bgChallenge.globalName, globalObj: window }) const webPoSignalOutput = [] const botGuardResponse = await botGuard.snapshot({ webPoSignalOutput }, 10_000) const integrityTokenResponse = await fetch(buildURL('GenerateIT', true), { method: 'POST', headers: { 'content-type': 'application/json+protobuf', 'x-goog-api-key': GOOG_API_KEY, 'x-user-agent': 'grpc-web-javascript/0.1', }, body: JSON.stringify([requestKey, botGuardResponse]) }) const response = await integrityTokenResponse.json() if (typeof response[0] !== 'string') { throw new Error('Could not get integrity token') } const integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken: response[0] }, webPoSignalOutput) return await integrityTokenBasedMinter.mintAsWebsafeString(videoId) } ================================================ FILE: src/constants.js ================================================ // IPC Channels const IpcChannels = { ENABLE_PROXY: 'enable-proxy', DISABLE_PROXY: 'disable-proxy', GET_SYSTEM_LOCALE: 'get-system-locale', GET_NAVIGATION_HISTORY: 'get-navigation-history', STOP_POWER_SAVE_BLOCKER: 'stop-power-save-blocker', START_POWER_SAVE_BLOCKER: 'start-power-save-blocker', CREATE_NEW_WINDOW: 'create-new-window', NATIVE_THEME_UPDATE: 'native-theme-update', APP_READY: 'app-ready', RELAUNCH_REQUEST: 'relaunch-request', SET_WINDOW_TITLE: 'set-window-title', SEARCH_INPUT_HANDLING_READY: 'search-input-handling-ready', UPDATE_SEARCH_INPUT_TEXT: 'update-search-input-text', OPEN_URL: 'open-url', CHANGE_VIEW: 'change-view', DB_SETTINGS: 'db-settings', DB_HISTORY: 'db-history', DB_PROFILES: 'db-profiles', DB_PLAYLISTS: 'db-playlists', DB_SEARCH_HISTORY: 'db-search-history', DB_SUBSCRIPTION_CACHE: 'db-subscription-cache', SYNC_SETTINGS: 'sync-settings', SYNC_HISTORY: 'sync-history', SYNC_SEARCH_HISTORY: 'sync-search-history', SYNC_PROFILES: 'sync-profiles', SYNC_PLAYLISTS: 'sync-playlists', SYNC_SUBSCRIPTION_CACHE: 'sync-subscription-cache', GET_REPLACE_HTTP_CACHE: 'get-replace-http-cache', TOGGLE_REPLACE_HTTP_CACHE: 'toggle-replace-http-cache', PLAYER_CACHE_GET: 'player-cache-get', PLAYER_CACHE_SET: 'player-cache-set', SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization', GENERATE_PO_TOKEN: 'generate-po-token', CHOOSE_DEFAULT_FOLDER: 'choose-default-folder', WRITE_TO_DEFAULT_FOLDER: 'write-to-default-folder', OPEN_IN_EXTERNAL_PLAYER: 'open-in-external-player', OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result' } const DBActions = { // The constants in the GENERAL group are usally intermingeled with the ones in other groups, so they need unique values. // The other groups however are usually not mixed (e.g. HISTORY and PROFILES), // so they can have similar values (as long as they don't overlap with the GENERAL group). GENERAL: { CREATE: 0, FIND: 1, UPSERT: 2, DELETE: 3, DELETE_MULTIPLE: 4, DELETE_ALL: 5, OVERWRITE: 6 }, HISTORY: { UPDATE_WATCH_PROGRESS: 20, UPDATE_PLAYLIST: 21, }, PROFILES: { ADD_CHANNEL: 20, REMOVE_CHANNEL: 21 }, PLAYLISTS: { UPSERT_VIDEO: 20, UPSERT_VIDEOS: 21, DELETE_VIDEO_ID: 22, DELETE_VIDEO_IDS: 23, DELETE_ALL_VIDEOS: 24, }, SUBSCRIPTION_CACHE: { UPDATE_VIDEOS_BY_CHANNEL: 20, UPDATE_LIVE_STREAMS_BY_CHANNEL: 21, UPDATE_SHORTS_BY_CHANNEL: 22, UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL: 23, UPDATE_COMMUNITY_POSTS_BY_CHANNEL: 24, }, } const SyncEvents = { // The constants in the GENERAL group are usally intermingeled with the ones in other groups, so they need unique values. // The other groups however are usually not mixed (e.g. HISTORY and PROFILES), // so they can have similar values (as long as they don't overlap with the GENERAL group). GENERAL: { CREATE: 0, UPSERT: 1, DELETE: 2, DELETE_MULTIPLE: 3, DELETE_ALL: 4, OVERWRITE: 5, }, HISTORY: { UPDATE_WATCH_PROGRESS: 20, UPDATE_PLAYLIST: 21, }, PROFILES: { ADD_CHANNEL: 20, REMOVE_CHANNEL: 21 }, PLAYLISTS: { UPSERT_VIDEO: 20, UPSERT_VIDEOS: 21, DELETE_VIDEO: 22, DELETE_VIDEOS: 23, }, SUBSCRIPTION_CACHE: { UPDATE_VIDEOS_BY_CHANNEL: 20, UPDATE_LIVE_STREAMS_BY_CHANNEL: 21, UPDATE_SHORTS_BY_CHANNEL: 22, UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL: 23, UPDATE_COMMUNITY_POSTS_BY_CHANNEL: 24, }, } /* DEV NOTE: Duplicate any and all changes made here to our [official documentation site here](https://github.com/FreeTubeApp/FreeTube-Docs/blob/master/usage/keyboard-shortcuts.md) to have them reflect on the [keyboard shortcut reference webpage](https://docs.freetubeapp.io/usage/keyboard-shortcuts). Please also update the [keyboard shortcut modal](src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue) */ const KeyboardShortcuts = { APP: { GENERAL: { SHOW_SHORTCUTS: 'shift+?', HISTORY_BACKWARD: 'alt+arrowleft', HISTORY_FORWARD: 'alt+arrowright', HISTORY_BACKWARD_ALT_MAC: 'cmd+[', HISTORY_FORWARD_ALT_MAC: 'cmd+]', FULLSCREEN: 'f11', NAVIGATE_TO_SETTINGS: 'ctrl+,', NAVIGATE_TO_HISTORY: 'ctrl+H', NAVIGATE_TO_HISTORY_MAC: 'cmd+Y', NEW_WINDOW: 'ctrl+N', MINIMIZE_WINDOW: 'ctrl+M', CLOSE_WINDOW: 'ctrl+W', TOGGLE_DEVTOOLS: 'ctrl+shift+I', FOCUS_SEARCH: 'alt+D', SEARCH_IN_NEW_WINDOW: 'shift+enter', RESET_ZOOM: 'ctrl+0', ZOOM_IN: 'ctrl+plus', ZOOM_OUT: 'ctrl+-' }, SITUATIONAL: { REFRESH: 'r', FOCUS_SECONDARY_SEARCH: 'ctrl+F' }, }, VIDEO_PLAYER: { GENERAL: { CAPTIONS: 'c', THEATRE_MODE: 't', FULLSCREEN: 'f', FULLWINDOW: 's', PICTURE_IN_PICTURE: 'i', MUTE: 'm', VOLUME_UP: 'arrowup', VOLUME_DOWN: 'arrowdown', STATS: 'd', TAKE_SCREENSHOT: 'u', }, PLAYBACK: { PLAY: 'k', LARGE_REWIND: 'j', LARGE_FAST_FORWARD: 'l', SMALL_REWIND: 'arrowleft', SMALL_FAST_FORWARD: 'arrowright', DECREASE_VIDEO_SPEED: 'o', DECREASE_VIDEO_SPEED_ALT: '<', INCREASE_VIDEO_SPEED: 'p', INCREASE_VIDEO_SPEED_ALT: '>', SKIP_N_TENTHS: '0..9', LAST_CHAPTER: 'ctrl+arrowleft', NEXT_CHAPTER: 'ctrl+arrowright', LAST_FRAME: ',', NEXT_FRAME: '.', HOME: 'home', END: 'end', SKIP_TO_NEXT: 'shift+n', SKIP_TO_PREV: 'shift+p' } }, } /** * Material Design Symbols used by FreeTube's custom player components * * This only has the value of the `d` attribute from the `` element, the rest of the SVG is generated at runtime. * * Fetched with * https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsrounded//default/24px.svg * https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsrounded//fill1/24px.svg */ const PlayerIcons = { CLOSE_FULLSCREEN_FILLED: 'M400-344 164-108q-11 11-28 11t-28-11q-11-11-11-28t11-28l236-236H200q-17 0-28.5-11.5T160-440q0-17 11.5-28.5T200-480h240q17 0 28.5 11.5T480-440v240q0 17-11.5 28.5T440-160q-17 0-28.5-11.5T400-200v-144Zm216-216h144q17 0 28.5 11.5T800-520q0 17-11.5 28.5T760-480H520q-17 0-28.5-11.5T480-520v-240q0-17 11.5-28.5T520-800q17 0 28.5 11.5T560-760v144l236-236q11-11 28-11t28 11q11 11 11 28t-11 28L616-560Z', DONE_FILLED: 'm382-354 339-339q12-12 28-12t28 12q12 12 12 28.5T777-636L410-268q-12 12-28 12t-28-12L182-440q-12-12-11.5-28.5T183-497q12-12 28.5-12t28.5 12l142 143Z', INSERT_CHART_DEFAULT: 'M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Zm120 200q-17 0-28.5 11.5T280-520v200q0 17 11.5 28.5T320-280q17 0 28.5-11.5T360-320v-200q0-17-11.5-28.5T320-560Zm160-120q-17 0-28.5 11.5T440-640v320q0 17 11.5 28.5T480-280q17 0 28.5-11.5T520-320v-320q0-17-11.5-28.5T480-680Zm160 240q-17 0-28.5 11.5T600-400v80q0 17 11.5 28.5T640-280q17 0 28.5-11.5T680-320v-80q0-17-11.5-28.5T640-440Z', INSERT_CHART_FILLED: 'M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm120-440q-17 0-28.5 11.5T280-520v200q0 17 11.5 28.5T320-280q17 0 28.5-11.5T360-320v-200q0-17-11.5-28.5T320-560Zm160-120q-17 0-28.5 11.5T440-640v320q0 17 11.5 28.5T480-280q17 0 28.5-11.5T520-320v-320q0-17-11.5-28.5T480-680Zm160 240q-17 0-28.5 11.5T600-400v80q0 17 11.5 28.5T640-280q17 0 28.5-11.5T680-320v-80q0-17-11.5-28.5T640-440Z', VARIABLES_DEFAULT: 'M120-320v-320q0-17 11.5-28.5T160-680h640q17 0 28.5 11.5T840-640v320q0 17-11.5 28.5T800-280H160q-17 0-28.5-11.5T120-320Zm80-40h560v-240H200v240Zm0 0v-240 240Z', OPEN_IN_FULL_FILLED: 'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z', PAUSE_CIRCLE_FILLED: 'M400-320q17 0 28.5-11.5T440-360v-240q0-17-11.5-28.5T400-640q-17 0-28.5 11.5T360-600v240q0 17 11.5 28.5T400-320Zm160 0q17 0 28.5-11.5T600-360v-240q0-17-11.5-28.5T560-640q-17 0-28.5 11.5T520-600v240q0 17 11.5 28.5T560-320ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z', PHOTO_CAMERA_FILLED: 'M480-260q75 0 127.5-52.5T660-440q0-75-52.5-127.5T480-620q-75 0-127.5 52.5T300-440q0 75 52.5 127.5T480-260Zm0-80q-42 0-71-29t-29-71q0-42 29-71t71-29q42 0 71 29t29 71q0 42-29 71t-71 29ZM160-120q-33 0-56.5-23.5T80-200v-480q0-33 23.5-56.5T160-760h126l50-54q11-12 26.5-19t32.5-7h170q17 0 32.5 7t26.5 19l50 54h126q33 0 56.5 23.5T880-680v480q0 33-23.5 56.5T800-120H160Z', PLAY_CIRCLE_FILLED: 'm426-330 195-125q14-9 14-25t-14-25L426-630q-15-10-30.5-1.5T380-605v250q0 18 15.5 26.5T426-330Zm54 250q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z', RECORD_VOICE_OVER_FILLED: 'M920-600q0 69-24.5 131.5T829-355q-12 14-30 15t-32-13q-13-13-12-31t12-33q30-38 46.5-85t16.5-98q0-51-16.5-97T767-781q-12-15-12.5-33t12.5-32q13-14 31.5-13.5T829-845q42 51 66.5 113.5T920-600Zm-182 0q0 32-10 61.5T700-484q-11 15-29.5 15.5T638-482q-13-13-13.5-31.5T633-549q6-11 9.5-24t3.5-27q0-14-3.5-27t-9.5-25q-9-17-8.5-35t13.5-31q14-14 32.5-13.5T700-716q18 25 28 54.5t10 61.5ZM360-440q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-200v-32q0-33 17-62t47-44q51-26 115-44t141-18q77 0 141 18t115 44q30 15 47 44t17 62v32q0 33-23.5 56.5T600-120H120q-33 0-56.5-23.5T40-200Z', TUNE_FILLED: 'M480-120q-17 0-28.5-11.5T440-160v-160q0-17 11.5-28.5T480-360q17 0 28.5 11.5T520-320v40h280q17 0 28.5 11.5T840-240q0 17-11.5 28.5T800-200H520v40q0 17-11.5 28.5T480-120Zm-320-80q-17 0-28.5-11.5T120-240q0-17 11.5-28.5T160-280h160q17 0 28.5 11.5T360-240q0 17-11.5 28.5T320-200H160Zm160-160q-17 0-28.5-11.5T280-400v-40H160q-17 0-28.5-11.5T120-480q0-17 11.5-28.5T160-520h120v-40q0-17 11.5-28.5T320-600q17 0 28.5 11.5T360-560v160q0 17-11.5 28.5T320-360Zm160-80q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520h320q17 0 28.5 11.5T840-480q0 17-11.5 28.5T800-440H480Zm160-160q-17 0-28.5-11.5T600-640v-160q0-17 11.5-28.5T640-840q17 0 28.5 11.5T680-800v40h120q17 0 28.5 11.5T840-720q0 17-11.5 28.5T800-680H680v40q0 17-11.5 28.5T640-600Zm-480-80q-17 0-28.5-11.5T120-720q0-17 11.5-28.5T160-760h320q17 0 28.5 11.5T520-720q0 17-11.5 28.5T480-680H160Z', RECTANGLE_DEFAULT: 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-480H160v480Zm0 0v-480 480Z', SKIP_NEXT_FILLED: 'M660-280v-400q0-17 11.5-28.5T700-720q17 0 28.5 11.5T740-680v400q0 17-11.5 28.5T700-240q-17 0-28.5-11.5T660-280Zm-440-35v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T548-480q0 10-4.5 18.5T530-447L282-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Z', SKIP_PREVIOUS_FILLED: 'M220-280v-400q0-17 11.5-28.5T260-720q17 0 28.5 11.5T300-680v400q0 17-11.5 28.5T260-240q-17 0-28.5-11.5T220-280Zm458-1L430-447q-9-6-13.5-14.5T412-480q0-10 4.5-18.5T430-513l248-166q5-4 11-5t11-1q16 0 28 11t12 29v330q0 18-12 29t-28 11q-5 0-11-1t-11-5Z' } const UnsupportedPlayerActions = /** @type {const} */({ STARTING_VIDEO_AT_OFFSET: 1, PLAYBACK_RATE: 2, OPENING_PLAYLISTS: 3, PLAYLIST_SPECIFIC_VIDEO: 4, PLAYLIST_REVERSE: 5, PLAYLIST_SHUFFLE: 6, PLAYLIST_LOOP: 7, }) /** * @typedef {UnsupportedPlayerActions[(keyof typeof UnsupportedPlayerActions)]} UnsupportedPlayerAction */ // Utils const MAIN_PROFILE_ID = 'allChannels' // Width threshold in px at which we switch to using a more heavily altered view for mobile users const MOBILE_WIDTH_THRESHOLD = 680 // Height threshold in px at which we switch to using a more heavily altered playlist view for mobile users const PLAYLIST_HEIGHT_FORCE_LIST_THRESHOLD = 500 // YouTube search character limit is 100 characters const SEARCH_CHAR_LIMIT = 100 // max # of results we show for search suggestions const SEARCH_RESULTS_DISPLAY_LIMIT = 14 // max # of search history results we show when mixed with YT search suggestions const MIXED_SEARCH_HISTORY_ENTRIES_DISPLAY_LIMIT = 4 // Displayed on the about page and used in the main.js file to only allow bitcoin URLs with this wallet address to be opened const ABOUT_BITCOIN_ADDRESS = '1Lih7Ho5gnxb1CwPD4o59ss78pwo2T91eS' export { IpcChannels, DBActions, SyncEvents, KeyboardShortcuts, PlayerIcons, UnsupportedPlayerActions, MAIN_PROFILE_ID, MOBILE_WIDTH_THRESHOLD, PLAYLIST_HEIGHT_FORCE_LIST_THRESHOLD, SEARCH_CHAR_LIMIT, SEARCH_RESULTS_DISPLAY_LIMIT, MIXED_SEARCH_HISTORY_ENTRIES_DISPLAY_LIMIT, ABOUT_BITCOIN_ADDRESS, } ================================================ FILE: src/data/.gitkeep ================================================ ================================================ FILE: src/datastores/handlers/base.js ================================================ import * as db from '../index' class Settings { static async find() { const currentLocale = await db.settings.findOneAsync({ _id: 'currentLocale' }) // In FreeTube 0.21.3 and earlier the locales 'en-GB', 'es-AR' and 'nb-NO' had underscores instead of a hyphens // This is a one time migration for users that are using one of those locales if (currentLocale?.value.includes('_')) { await this.upsert('currentLocale', currentLocale.value.replace('_', '-')) } // In FreeTube 0.22.0 and earlier the external player arguments were displayed in a text box, // with the user manually entering `;` to separate the different arguments. // This is a one time migration that converts the old string to a JSON array const externalPlayerCustomArgs = await db.settings.findOneAsync({ _id: 'externalPlayerCustomArgs' }) if (externalPlayerCustomArgs && !externalPlayerCustomArgs.value.startsWith('[')) { let newValue = '[]' if (externalPlayerCustomArgs.value.length > 0) { newValue = JSON.stringify(externalPlayerCustomArgs.value.split(';')) } await this.upsert('externalPlayerCustomArgs', newValue) } // In FreeTube 0.23.0, the "Enable Theatre Mode by Default" setting was incoporated as an option // of the "Default Viewing Mode" setting. This is a one time migration to preserve users' // Theater Mode preference through this change. const defaultTheatreMode = await db.settings.findOneAsync({ _id: 'defaultTheatreMode' }) if (defaultTheatreMode) { if (defaultTheatreMode.value) { await this.upsert('defaultViewingMode', 'theatre') } await db.settings.removeAsync({ _id: 'defaultTheatreMode' }) } const saveWatchedProgress = await db.settings.findOneAsync({ _id: 'saveWatchedProgress' }) const watchedProgressSavingMode = await db.settings.findOneAsync({ _id: 'watchedProgressSavingMode' }) if (saveWatchedProgress && !watchedProgressSavingMode) { if (!saveWatchedProgress.value) { await this.upsert('watchedProgressSavingMode', 'never') } await db.settings.removeAsync({ _id: 'saveWatchedProgress' }) } return db.settings.findAsync({ _id: { $ne: 'bounds' } }) } static upsert(_id, value) { return db.settings.updateAsync({ _id }, { _id, value }, { upsert: true }) } // ******************** // // Unique Electron main process handlers static _findAppReadyRelatedSettings() { return db.settings.findAsync({ _id: { $in: [ 'disableSmoothScrolling', 'useProxy', 'proxyProtocol', 'proxyHostname', 'proxyPort', 'backendFallback', 'backendPreference', 'hideToTrayOnMinimize' ] } }) } static _findOne(_id) { return db.settings.findOneAsync({ _id }) } static _findSidenavSettings() { return { hideTrendingVideos: db.settings.findOneAsync({ _id: 'hideTrendingVideos' }), hidePopularVideos: db.settings.findOneAsync({ _id: 'hidePopularVideos' }), hidePlaylists: db.settings.findOneAsync({ _id: 'hidePlaylists' }), } } static _updateBounds(value) { return db.settings.updateAsync({ _id: 'bounds' }, { _id: 'bounds', value }, { upsert: true }) } // ******************** // } class History { static find() { return db.history.findAsync({}).sort({ timeWatched: -1 }) } static upsert(record) { return db.history.updateAsync({ videoId: record.videoId }, record, { upsert: true }) } static async overwrite(records) { await db.history.removeAsync({}, { multi: true }) await db.history.insertAsync(records) } static updateWatchProgress(videoId, watchProgress) { return db.history.updateAsync({ videoId }, { $set: { watchProgress } }, { upsert: true }) } static updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) { return db.history.updateAsync({ videoId }, { $set: { lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId } }, { upsert: true }) } static delete(videoId) { return db.history.removeAsync({ videoId }) } static deleteAll() { return db.history.removeAsync({}, { multi: true }) } } class Profiles { static create(profile) { return db.profiles.insertAsync(profile) } static find() { return db.profiles.findAsync({}) } static upsert(profile) { return db.profiles.updateAsync({ _id: profile._id }, profile, { upsert: true }) } static addChannelToProfiles(channel, profileIds) { if (profileIds.length === 1) { return db.profiles.updateAsync( { _id: profileIds[0] }, { $push: { subscriptions: channel } } ) } else { return db.profiles.updateAsync( { _id: { $in: profileIds } }, { $push: { subscriptions: channel } }, { multi: true } ) } } static removeChannelFromProfiles(channelId, profileIds) { if (profileIds.length === 1) { return db.profiles.updateAsync( { _id: profileIds[0] }, { $pull: { subscriptions: { id: channelId } } } ) } else { return db.profiles.updateAsync( { _id: { $in: profileIds } }, { $pull: { subscriptions: { id: channelId } } }, { multi: true } ) } } static delete(id) { return db.profiles.removeAsync({ _id: id }) } } class Playlists { static create(playlists) { return db.playlists.insertAsync(playlists) } static find() { return db.playlists.findAsync({}) } static upsert(playlist) { return db.playlists.updateAsync({ _id: playlist._id }, { $set: playlist }, { upsert: true }) } static upsertVideoByPlaylistId(_id, lastUpdatedAt, videoData) { return db.playlists.updateAsync( { _id }, { $push: { videos: videoData }, $set: { lastUpdatedAt } }, { upsert: true } ) } static upsertVideosByPlaylistId(_id, lastUpdatedAt, videos) { return db.playlists.updateAsync( { _id }, { $push: { videos: { $each: videos } }, $set: { lastUpdatedAt } }, { upsert: true } ) } static delete(_id) { return db.playlists.removeAsync({ _id, protected: { $ne: true } }) } static deleteVideoIdByPlaylistId(_id, lastUpdatedAt, videoId, playlistItemId) { if (playlistItemId != null) { return db.playlists.updateAsync( { _id }, { $pull: { videos: { playlistItemId } }, $set: { lastUpdatedAt } }, { upsert: true } ) } else if (videoId != null) { return db.playlists.updateAsync( { _id }, { $pull: { videos: { videoId } }, $set: { lastUpdatedAt } }, { upsert: true } ) } else { throw new Error(`Both videoId & playlistItemId are absent, _id: ${_id}`) } } static deleteVideoIdsByPlaylistId(_id, lastUpdatedAt, playlistItemIds) { return db.playlists.updateAsync( { _id }, { $pull: { videos: { playlistItemId: { $in: playlistItemIds } } }, $set: { lastUpdatedAt } }, { upsert: true } ) } static deleteAllVideosByPlaylistId(_id) { return db.playlists.updateAsync( { _id }, { $set: { videos: [] } }, { upsert: true } ) } static deleteMultiple(ids) { return db.playlists.removeAsync({ _id: { $in: ids }, protected: { $ne: true } }) } static deleteAll() { return db.playlists.removeAsync({}, { multi: true }) } } class SearchHistory { static find() { return db.searchHistory.findAsync({}).sort({ lastUpdatedAt: -1 }) } static upsert(searchHistoryEntry) { return db.searchHistory.updateAsync({ _id: searchHistoryEntry._id }, searchHistoryEntry, { upsert: true }) } static async overwrite(records) { await db.searchHistory.removeAsync({}, { multi: true }) await db.searchHistory.insertAsync(records) } static delete(_id) { return db.searchHistory.removeAsync({ _id: _id }) } static deleteAll() { return db.searchHistory.removeAsync({}, { multi: true }) } } class SubscriptionCache { static find() { return db.subscriptionCache.findAsync({}) } static updateVideosByChannelId(channelId, entries, timestamp) { return db.subscriptionCache.updateAsync( { _id: channelId }, { $set: { videos: entries, videosTimestamp: timestamp } }, { upsert: true } ) } static updateLiveStreamsByChannelId(channelId, entries, timestamp) { return db.subscriptionCache.updateAsync( { _id: channelId }, { $set: { liveStreams: entries, liveStreamsTimestamp: timestamp } }, { upsert: true } ) } static updateShortsByChannelId(channelId, entries, timestamp) { return db.subscriptionCache.updateAsync( { _id: channelId }, { $set: { shorts: entries, shortsTimestamp: timestamp } }, { upsert: true } ) } static async updateShortsWithChannelPageShortsByChannelId(channelId, entries) { const doc = await db.subscriptionCache.findOneAsync({ _id: channelId }, { shorts: 1 }) if (!Array.isArray(doc?.shorts)) { return } let hasUpdates = false doc.shorts.forEach(cachedVideo => { const channelVideo = entries.find(short => cachedVideo.videoId === short.videoId) if (!channelVideo) { return } hasUpdates = true // authorId probably never changes, so we don't need to update that cachedVideo.title = channelVideo.title cachedVideo.author = channelVideo.author // as the channel shorts page only has compact view counts for numbers above 1000 e.g. 12k // and the RSS feeds include an exact value, we only want to overwrite it when the number is larger than the cached value // 12345 vs 12000 => 12345 // 12345 vs 15000 => 15000 if (channelVideo.viewCount > cachedVideo.viewCount) { cachedVideo.viewCount = channelVideo.viewCount } }) if (hasUpdates) { await db.subscriptionCache.updateAsync( { _id: channelId }, { $set: { shorts: doc.shorts } } ) } } static updateCommunityPostsByChannelId(channelId, entries, timestamp) { return db.subscriptionCache.updateAsync( { _id: channelId }, { $set: { communityPosts: entries, communityPostsTimestamp: timestamp } }, { upsert: true } ) } static deleteMultipleChannels(channelIds) { return db.subscriptionCache.removeAsync({ _id: { $in: channelIds } }, { multi: true }) } static deleteAll() { return db.subscriptionCache.removeAsync({}, { multi: true }) } } function loadDatastores() { return Promise.allSettled([ db.settings.loadDatabaseAsync(), db.history.loadDatabaseAsync(), db.profiles.loadDatabaseAsync(), db.playlists.loadDatabaseAsync(), db.searchHistory.loadDatabaseAsync(), db.subscriptionCache.loadDatabaseAsync(), ]) } function compactAllDatastores() { return Promise.allSettled([ db.settings.compactDatafileAsync(), db.history.compactDatafileAsync(), db.profiles.compactDatafileAsync(), db.playlists.compactDatafileAsync(), db.searchHistory.compactDatafileAsync(), db.subscriptionCache.compactDatafileAsync(), ]) } export { Settings as settings, History as history, Profiles as profiles, Playlists as playlists, SearchHistory as searchHistory, SubscriptionCache as subscriptionCache, loadDatastores, compactAllDatastores, } ================================================ FILE: src/datastores/handlers/electron.js ================================================ import { DBActions } from '../../constants' class Settings { static find() { return window.ftElectron.dbSettings(DBActions.GENERAL.FIND) } static upsert(_id, value) { return window.ftElectron.dbSettings(DBActions.GENERAL.UPSERT, { _id, value }) } } class History { static find() { return window.ftElectron.dbHistory(DBActions.GENERAL.FIND) } static upsert(record) { return window.ftElectron.dbHistory(DBActions.GENERAL.UPSERT, record) } static overwrite(records) { return window.ftElectron.dbHistory(DBActions.GENERAL.OVERWRITE, records) } static updateWatchProgress(videoId, watchProgress) { return window.ftElectron.dbHistory( DBActions.HISTORY.UPDATE_WATCH_PROGRESS, { videoId, watchProgress } ) } static updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) { return window.ftElectron.dbHistory( DBActions.HISTORY.UPDATE_PLAYLIST, { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId } ) } static delete(videoId) { return window.ftElectron.dbHistory(DBActions.GENERAL.DELETE, videoId) } static deleteAll() { return window.ftElectron.dbHistory(DBActions.GENERAL.DELETE_ALL) } } class Profiles { static create(profile) { return window.ftElectron.dbProfiles(DBActions.GENERAL.CREATE, profile) } static find() { return window.ftElectron.dbProfiles(DBActions.GENERAL.FIND) } static upsert(profile) { return window.ftElectron.dbProfiles(DBActions.GENERAL.UPSERT, profile) } static addChannelToProfiles(channel, profileIds) { return window.ftElectron.dbProfiles(DBActions.PROFILES.ADD_CHANNEL, { channel, profileIds }) } static removeChannelFromProfiles(channelId, profileIds) { return window.ftElectron.dbProfiles(DBActions.PROFILES.REMOVE_CHANNEL, { channelId, profileIds }) } static delete(id) { return window.ftElectron.dbProfiles(DBActions.GENERAL.DELETE, id) } } class Playlists { static create(playlists) { return window.ftElectron.dbPlaylists(DBActions.GENERAL.CREATE, playlists) } static find() { return window.ftElectron.dbPlaylists(DBActions.GENERAL.FIND) } static upsert(playlist) { return window.ftElectron.dbPlaylists(DBActions.GENERAL.UPSERT, playlist) } static upsertVideoByPlaylistId(_id, lastUpdatedAt, videoData) { return window.ftElectron.dbPlaylists( DBActions.PLAYLISTS.UPSERT_VIDEO, { _id, lastUpdatedAt, videoData } ) } static upsertVideosByPlaylistId(_id, lastUpdatedAt, videos) { return window.ftElectron.dbPlaylists( DBActions.PLAYLISTS.UPSERT_VIDEOS, { _id, lastUpdatedAt, videos } ) } static delete(_id) { return window.ftElectron.dbPlaylists(DBActions.GENERAL.DELETE, _id) } static deleteVideoIdByPlaylistId(_id, lastUpdatedAt, videoId, playlistItemId) { return window.ftElectron.dbPlaylists( DBActions.PLAYLISTS.DELETE_VIDEO_ID, { _id, lastUpdatedAt, videoId, playlistItemId } ) } static deleteVideoIdsByPlaylistId(_id, lastUpdatedAt, playlistItemIds) { return window.ftElectron.dbPlaylists( DBActions.PLAYLISTS.DELETE_VIDEO_IDS, { _id, lastUpdatedAt, playlistItemIds } ) } static deleteAllVideosByPlaylistId(_id) { return window.ftElectron.dbPlaylists(DBActions.PLAYLISTS.DELETE_ALL_VIDEOS, _id) } static deleteMultiple(ids) { return window.ftElectron.dbPlaylists(DBActions.GENERAL.DELETE_MULTIPLE, ids) } static deleteAll() { return window.ftElectron.dbPlaylists(DBActions.GENERAL.DELETE_ALL) } } class SearchHistory { static find() { return window.ftElectron.dbSearchHistory(DBActions.GENERAL.FIND) } static upsert(searchHistoryEntry) { return window.ftElectron.dbSearchHistory(DBActions.GENERAL.UPSERT, searchHistoryEntry) } static overwrite(records) { return window.ftElectron.dbSearchHistory(DBActions.GENERAL.OVERWRITE, records) } static delete(_id) { return window.ftElectron.dbSearchHistory(DBActions.GENERAL.DELETE, _id) } static deleteAll() { return window.ftElectron.dbSearchHistory(DBActions.GENERAL.DELETE_ALL) } } class SubscriptionCache { static find() { return window.ftElectron.dbSubscriptionCache(DBActions.GENERAL.FIND) } static updateVideosByChannelId(channelId, entries, timestamp) { return window.ftElectron.dbSubscriptionCache( DBActions.SUBSCRIPTION_CACHE.UPDATE_VIDEOS_BY_CHANNEL, { channelId, entries, timestamp } ) } static updateLiveStreamsByChannelId(channelId, entries, timestamp) { return window.ftElectron.dbSubscriptionCache( DBActions.SUBSCRIPTION_CACHE.UPDATE_LIVE_STREAMS_BY_CHANNEL, { channelId, entries, timestamp } ) } static updateShortsByChannelId(channelId, entries, timestamp) { return window.ftElectron.dbSubscriptionCache( DBActions.SUBSCRIPTION_CACHE.UPDATE_SHORTS_BY_CHANNEL, { channelId, entries, timestamp } ) } static updateShortsWithChannelPageShortsByChannelId(channelId, entries) { return window.ftElectron.dbSubscriptionCache( DBActions.SUBSCRIPTION_CACHE.UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL, { channelId, entries } ) } static updateCommunityPostsByChannelId(channelId, entries, timestamp) { return window.ftElectron.dbSubscriptionCache( DBActions.SUBSCRIPTION_CACHE.UPDATE_COMMUNITY_POSTS_BY_CHANNEL, { channelId, entries, timestamp } ) } static deleteMultipleChannels(channelIds) { return window.ftElectron.dbSubscriptionCache(DBActions.GENERAL.DELETE_MULTIPLE, channelIds) } static deleteAll() { return window.ftElectron.dbSubscriptionCache(DBActions.GENERAL.DELETE_ALL) } } export { Settings as settings, History as history, Profiles as profiles, Playlists as playlists, SearchHistory as searchHistory, SubscriptionCache as subscriptionCache, } ================================================ FILE: src/datastores/handlers/index.js ================================================ export { settings as DBSettingHandlers, history as DBHistoryHandlers, profiles as DBProfileHandlers, playlists as DBPlaylistHandlers, searchHistory as DBSearchHistoryHandlers, subscriptionCache as DBSubscriptionCacheHandlers, } from 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB' ================================================ FILE: src/datastores/handlers/web.js ================================================ import * as baseHandlers from './base' // TODO: Syncing // Syncing on the web would involve a different implementation // to the electron one (obviously) // One idea would be to use a watcher-like mechanism on // localStorage or IndexedDB to inform other tabs on the changes // that have occurred in other tabs // // NOTE: NeDB uses `localForage` on the browser // https://www.npmjs.com/package/localforage class Settings { static find() { return baseHandlers.settings.find() } static upsert(_id, value) { return baseHandlers.settings.upsert(_id, value) } } // For the settings we use the wrapper class to hide some methods only needed in the Electron main process export { Settings as settings } // These classes don't require any changes from the base classes, so can be exported as-is. export { history, profiles, playlists, searchHistory, subscriptionCache } from './base' ================================================ FILE: src/datastores/index.js ================================================ import Datastore from '@seald-io/nedb' let dbPath = null if (process.env.IS_ELECTRON_MAIN) { const { app } = require('electron') const { join } = require('path') // this code only runs in the electron main process, so hopefully using sync fs code here should be fine 😬 const { statSync, realpathSync } = require('fs') const userDataPath = app.getPath('userData') // This is based on the user's OS dbPath = (dbName) => { let path = join(userDataPath, `${dbName}.db`) // returns undefined if the path doesn't exist if (statSync(path, { throwIfNoEntry: false })?.isSymbolicLink) { path = realpathSync(path) } return path } } else { dbPath = (dbName) => `${dbName}.db` } /** * @param {string} name */ function createDatastore(name) { return new Datastore({ filename: dbPath(name), autoload: !process.env.IS_ELECTRON_MAIN, // Automatically clean up corrupted data, instead of crashing corruptAlertThreshold: 1 }) } export const settings = createDatastore('settings') export const profiles = createDatastore('profiles') export const playlists = createDatastore('playlists') export const history = createDatastore('history') export const searchHistory = createDatastore('search-history') export const subscriptionCache = createDatastore('subscription-cache') ================================================ FILE: src/index.ejs ================================================ <% if (!process.env.IS_ELECTRON) { %> <% } %>
<% if (process.env.IS_ELECTRON) { %> <% } else { %> <% } %> ================================================ FILE: src/main/ImageCache.js ================================================ // cleanup expired images once every 5 mins const CLEANUP_INTERVAL = 300_000 // images expire after 2 hours if no expiry information is found in the http headers const FALLBACK_MAX_AGE = 7200 export class ImageCache { constructor() { this._cache = new Map() setInterval(this._cleanup.bind(this), CLEANUP_INTERVAL) } add(url, mimeType, data, expiry) { this._cache.set(url, { mimeType, data, expiry }) } has(url) { return this._cache.has(url) } get(url) { const entry = this._cache.get(url) if (!entry) { // this should never happen as the `has` method should be used to check for the existence first throw new Error(`No image cache entry for ${url}`) } return { data: entry.data, mimeType: entry.mimeType } } _cleanup() { // seconds since 1970-01-01 00:00:00 const now = Math.trunc(Date.now() / 1000) for (const [key, entry] of this._cache.entries()) { if (entry.expiry <= now) { this._cache.delete(key) } } } } /** * Extracts the cache expiry timestamp of image from HTTP headers * @param {Record} headers * @returns a timestamp in seconds */ export function extractExpiryTimestamp(headers) { const maxAgeRegex = /max-age=(\d+)/ const cacheControl = headers['cache-control'] if (cacheControl && maxAgeRegex.test(cacheControl)) { let maxAge = parseInt(cacheControl.match(maxAgeRegex)[1]) if (headers.age) { maxAge -= parseInt(headers.age) } // we don't need millisecond precision, so we can store it as seconds to use less memory return Math.trunc(Date.now() / 1000) + maxAge } else if (headers.expires) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires return Math.trunc(Date.parse(headers.expires) / 1000) } else { return Math.trunc(Date.now() / 1000) + FALLBACK_MAX_AGE } } ================================================ FILE: src/main/externalPlayer.js ================================================ import { spawn } from 'node:child_process' import { join } from 'node:path' import { readFile } from 'node:fs/promises' import { settings } from '../datastores/handlers/base' import { isFreeTubeUrl } from './utils' import { IpcChannels, UnsupportedPlayerActions } from '../constants' /** * @typedef ExternalPlayerPayload * @property {string | undefined | null} [videoId] * @property {string | undefined | null} [playlistId] * @property {number | undefined | null} [startTime] * @property {number | undefined | null} [playbackRate] * @property {number | undefined | null} [playlistIndex] * @property {boolean | undefined | null} [playlistReverse] * @property {boolean | undefined | null} [playlistShuffle] * @property {boolean | undefined | null} [playlistLoop] */ /** * @typedef CmdArgs * @property {string} defaultExecutable * @property {string[] | null} defaultCustomArguments * @property {string} videoUrl * @property {string | null} playlistUrl * @property {string | null} startOffset * @property {string | null} playbackRate * @property {string | null} playlistIndex * @property {string | null} playlistReverse * @property {string | null} playlistShuffle * @property {string | null} playlistLoop */ const ID_REGEX = /^[\w-]+$/ /** @type {Map} */ const externalPlayerCmdArgs = new Map() /** * @param {import('electron').IpcMainEvent} event * @param {ExternalPlayerPayload} payload */ export async function handleOpenInExternalPlayer(event, payload) { if (!isFreeTubeUrl(event.senderFrame.url) || !event.sender.isFocused()) { return } const hasValidVideoId = typeof payload.videoId === 'string' && payload.videoId.length === 11 && ID_REGEX.test(payload.videoId) const hasValidPlaylistId = typeof payload.playlistId === 'string' && payload.playlistId.length > 2 && ID_REGEX.test(payload.playlistId) if (!hasValidVideoId && !hasValidPlaylistId) { return } /** @type {string} */ const externalPlayer = (await settings._findOne('externalPlayer'))?.value || '' // External player setting not set or set to "none" if (externalPlayer === '') { return } if (externalPlayerCmdArgs.size === 0) { await loadExternalPlayerData() } const cmdArgs = externalPlayerCmdArgs.get(externalPlayer) if (cmdArgs === undefined) { return } const args = [] /** @type {import('../constants').UnsupportedPlayerAction[]} */ const unsupportedActions = [] /** @type {boolean} */ const ignoreWarnings = (await settings._findOne('externalPlayerIgnoreWarnings'))?.value || false /** @type {boolean} */ const ignoreDefaultArgs = (await settings._findOne('externalPlayerIgnoreDefaultArgs'))?.value || false /** @type {string[] | string} */ const customArgs = (await settings._findOne('externalPlayerCustomArgs'))?.value || '[]' if (typeof customArgs === 'string' && customArgs !== '[]') { args.push(...JSON.parse(customArgs)) } else if (!ignoreDefaultArgs && Array.isArray(cmdArgs.defaultCustomArguments)) { args.push(...cmdArgs.defaultCustomArguments) } if (ignoreDefaultArgs) { if (hasValidVideoId) { args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`) } } else { if (typeof payload.startTime === 'number' && payload.startTime > 0) { if (typeof cmdArgs.startOffset === 'string') { if (cmdArgs.defaultExecutable.startsWith('mpc')) { // For mpc-hc and mpc-be, which require startOffset to be in milliseconds args.push(cmdArgs.startOffset, 1000 * Math.trunc(payload.startTime)) } else if (cmdArgs.startOffset.endsWith('=')) { // For players using `=` in arguments // e.g. vlc --start-time=xxxxx args.push(`${cmdArgs.startOffset}${payload.startTime}`) } else { // For players using space in arguments // e.g. smplayer -start xxxxx args.push(cmdArgs.startOffset, Math.trunc(payload.startTime)) } } else if (!ignoreWarnings) { unsupportedActions.push(UnsupportedPlayerActions.STARTING_VIDEO_AT_OFFSET) } } if (typeof payload.playbackRate === 'number' && payload.playbackRate > 0) { if (typeof cmdArgs.playbackRate === 'string') { args.push(`${cmdArgs.playbackRate}${payload.playbackRate}`) } else if (!ignoreWarnings) { unsupportedActions.push(UnsupportedPlayerActions.PLAYBACK_RATE) } } // Check whether the video is in a playlist if (hasValidPlaylistId && typeof cmdArgs.playlistUrl === 'string') { if (typeof payload.playlistIndex === 'number' && payload.playlistIndex >= 0) { if (typeof cmdArgs.playlistIndex === 'string') { args.push(`${cmdArgs.playlistIndex}${payload.playlistIndex}`) } else if (!ignoreWarnings) { unsupportedActions.push(UnsupportedPlayerActions.PLAYLIST_SPECIFIC_VIDEO) } } if (payload.playlistReverse) { if (typeof cmdArgs.playlistReverse === 'string') { args.push(cmdArgs.playlistReverse) } else if (!ignoreWarnings) { unsupportedActions.push(UnsupportedPlayerActions.PLAYLIST_REVERSE) } } if (payload.playlistShuffle) { if (typeof cmdArgs.playlistShuffle === 'string') { args.push(cmdArgs.playlistShuffle) } else if (!ignoreWarnings) { unsupportedActions.push(UnsupportedPlayerActions.PLAYLIST_SHUFFLE) } } if (payload.playlistLoop) { if (typeof cmdArgs.playlistLoop === 'string') { args.push(cmdArgs.playlistLoop) } else if (!ignoreWarnings) { unsupportedActions.push(UnsupportedPlayerActions.PLAYLIST_LOOP) } } // If the player supports opening playlists but not indexes, send only the video URL if an index is specified if (cmdArgs.playlistIndex == null && typeof payload.playlistIndex === 'number') { args.push(`${cmdArgs.videoUrl}https://youtube.com/watch?v=${payload.videoId}`) } else { args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`) } } else { if (hasValidPlaylistId && !ignoreWarnings) { unsupportedActions.push(UnsupportedPlayerActions.OPENING_PLAYLISTS) } if (hasValidVideoId) { args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`) } } } event.reply( IpcChannels.OPEN_IN_EXTERNAL_PLAYER_RESULT, externalPlayer, unsupportedActions, hasValidPlaylistId ) /** @type {string} */ const externalPlayerExecutable = (await settings._findOne('externalPlayerExecutable'))?.value || '' const executable = externalPlayerExecutable.length > 0 ? externalPlayerExecutable : cmdArgs.defaultExecutable const child = spawn(executable, args, { detached: true, stdio: 'ignore' }) child.unref() } async function loadExternalPlayerData() { const path = process.env.NODE_ENV === 'development' ? '../../static/external-player-map.json' : 'static/external-player-map.json' const json = JSON.parse(await readFile(join(__dirname, path))) for (const entry of json) { if (entry.value.length > 0) { externalPlayerCmdArgs.set(entry.value, entry.cmdArguments) } } } ================================================ FILE: src/main/index.js ================================================ import { app, BrowserWindow, dialog, Menu, ipcMain, powerSaveBlocker, screen, session, shell, nativeTheme, net, protocol, clipboard, Tray } from 'electron' import path from 'path' import cp from 'child_process' import { IpcChannels, DBActions, SyncEvents, ABOUT_BITCOIN_ADDRESS, KeyboardShortcuts, SEARCH_CHAR_LIMIT, } from '../constants' import * as baseHandlers from '../datastores/handlers/base' import { extractExpiryTimestamp, ImageCache } from './ImageCache' import { constants as fsConstants, existsSync } from 'fs' import asyncFs from 'fs/promises' import { promisify } from 'util' import { brotliDecompress } from 'zlib' import contextMenu from 'electron-context-menu' import packageDetails from '../../package.json' import { handleOpenInExternalPlayer } from './externalPlayer' import { generatePoToken } from './poTokenGenerator' import { isFreeTubeUrl } from './utils' const brotliDecompressAsync = promisify(brotliDecompress) if (process.argv.includes('--version')) { console.log(`v${packageDetails.version} Beta`) // eslint-disable-line no-console app.exit() } else if (process.argv.includes('--help') || process.argv.includes('-h')) { printHelp() app.exit() } else { // Only allow single instance of the application // Exit if we didn't get the lock, because another instance already has it if (process.env.NODE_ENV !== 'development' && !app.requestSingleInstanceLock()) { app.exit() } else { baseHandlers.loadDatastores() runApp() } } function printHelp() { // eslint-disable-next-line no-console console.log(`\ usage: ${process.argv0} [options...] [url] Options: --help, -h show this message, then exit --version print the current version, then exit --new-window reuse an existing instance if possible`) } function runApp() { /** @type {Set} */ const ALLOWED_RENDERER_FILES = process.env.NODE_ENV === 'production' // __FREETUBE_ALLOWED_PATHS__ is replaced by the injectAllowedPaths.mjs script ? new Set(__FREETUBE_ALLOWED_PATHS__) : new Set() if (process.env.NODE_ENV === 'production') { protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } }]) } const ROOT_APP_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:9080' : 'app://bundle/index.html' let backendPreference = 'local' let backendFallback = true contextMenu({ showSearchWithGoogle: false, showSaveImageAs: true, showCopyImageAddress: true, showSelectAll: false, showCopyLink: false, prepend: (defaultActions, parameters, browserWindow) => [ { label: 'Open in a New Window', // Only show the option for in-app URLs and not external ones visible: parameters.linkURL.split('#')[0] === browserWindow.webContents.getURL().split('#')[0], click: () => { createWindow({ replaceMainWindow: false, windowStartupUrl: parameters.linkURL, showWindowNow: true }) } }, // Only show select all in text fields { label: 'Select All', enabled: parameters.editFlags.canSelectAll, visible: parameters.isEditable, click: () => { browserWindow.webContents.selectAll() } } ], // only show the copy link entry for external links and the /playlist, /channel and /watch in-app URLs // the /playlist, /channel and /watch in-app URLs get transformed to their equivalent YouTube or Invidious URLs append: (defaultActions, parameters, browserWindow) => { let visible = false const urlParts = parameters.linkURL.split('#') const isInAppUrl = urlParts[0] === browserWindow.webContents.getURL().split('#')[0] if (parameters.linkURL.length > 0) { if (isInAppUrl) { const path = urlParts[1] if (path) { visible = ['/channel', '/watch', '/hashtag', '/post'].some(p => path.startsWith(p)) || // Only show copy link entry for non user playlists (path.startsWith('/playlist') && !/playlistType=user/.test(path)) } } else { visible = true } } const copy = (url) => { if (parameters.linkText) { clipboard.write({ bookmark: parameters.linkText, text: url }) } else { clipboard.writeText(url) } } const transformURL = (toYouTube) => { let origin if (toYouTube) { origin = 'https://www.youtube.com' } else { origin = 'https://redirect.invidious.io' } const [path, query] = urlParts[1].split('?') const [route, id] = path.split('/').filter(p => p) switch (route) { case 'playlist': return `${origin}/playlist?list=${id}` case 'channel': return `${origin}/channel/${id}` case 'hashtag': return `${origin}/hashtag/${id}` case 'watch': { let url if (toYouTube) { url = new URL(`https://youtu.be/${id}`) } else { url = new URL(`https://redirect.invidious.io/watch?v=${id}`) } if (query) { const params = new URLSearchParams(query) const newParams = new URLSearchParams(url.search) let hasParams = false if (params.has('playlistId') && params.get('playlistType') !== 'user') { newParams.set('list', params.get('playlistId')) hasParams = true } if (params.has('timestamp')) { newParams.set('t', params.get('timestamp')) hasParams = true } if (hasParams) { url.search = newParams.toString() } } return url.toString() } case 'post': { if (query) { const authorId = new URLSearchParams(query).get('authorId') if (authorId) { if (toYouTube) { return `${origin}/channel/${authorId}/community?lb=${id}` } else { return `${origin}/post/${id}?ucid=${authorId}` } } } return `${origin}/post/${id}` } } } const textShortEnoughForSearch = parameters.selectionText.trim().length <= SEARCH_CHAR_LIMIT return [ { label: 'Copy Lin&k', visible: visible && !isInAppUrl, click: () => { copy(parameters.linkURL) } }, { label: 'Copy YouTube Link', visible: visible && isInAppUrl, click: () => { copy(transformURL(true)) } }, { label: 'Copy Invidious Link', visible: visible && isInAppUrl && (backendPreference === 'invidious' || backendFallback), click: () => { copy(transformURL(false)) } }, // Only show search in new window for // Static text or link // NOT internal link // NOT link with no customized link text // NOT link for timestamp { label: textShortEnoughForSearch ? 'Search “{selection}” in a New Window' : `“{selection}” is too long for search (> ${SEARCH_CHAR_LIMIT} chars)`, enabled: textShortEnoughForSearch, visible: ( !isInAppUrl && !parameters.isEditable && (parameters.linkURL != null && !parameters.linkURL.includes(parameters.selectionText) && !(/(\d{1,2}:)*\d{1,2}:\d{2}/.test(parameters.linkText))) && parameters.selectionText.trim().length > 0 ), click: () => { const queryText = parameters.selectionText.trim() createWindow({ replaceMainWindow: false, windowStartupUrl: `${ROOT_APP_URL}#/search/${encodeURIComponent(queryText)}`, searchQueryText: queryText, showWindowNow: true, }) } }, ] }, }) if (process.platform === 'win32') { app.setUserTasks([ { program: process.execPath, arguments: '--new-window', iconPath: process.execPath, iconIndex: 0, title: 'New Window', description: 'Open New Window' } ]) } // disable electron warning process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' const isDebug = process.argv.includes('--debug') let mainWindow let startupUrl let tray = null let trayOnMinimize = false let trayWindows = [] const trayMaximizedWindows = {} const userDataPath = app.getPath('userData') // command line switches need to be added before the app ready event first // that means we can't use the normal settings system as that is asynchronous, // doing it synchronously ensures that we add it before the event fires const REPLACE_HTTP_CACHE_PATH = `${userDataPath}/experiment-replace-http-cache` const replaceHttpCache = existsSync(REPLACE_HTTP_CACHE_PATH) if (replaceHttpCache) { // the http cache causes excessive disk usage during video playback // we've got a custom image cache to make up for disabling the http cache // experimental as it increases RAM use in favour of reduced disk use app.commandLine.appendSwitch('disable-http-cache') } const PLAYER_CACHE_PATH = `${userDataPath}/player_cache` // See: https://stackoverflow.com/questions/45570589/electron-protocol-handler-not-working-on-windows // remove so we can register each time as we run the app. app.removeAsDefaultProtocolClient('freetube') // If we are running a non-packaged version of the app && on windows if (process.env.NODE_ENV === 'development' && process.platform === 'win32') { // Set the path of electron.exe and your app. // These two additional parameters are only available on windows. app.setAsDefaultProtocolClient('freetube', process.execPath, [path.resolve(process.argv[1])]) } else { app.setAsDefaultProtocolClient('freetube') } if (process.env.NODE_ENV !== 'development') { app.on('second-instance', async (_, commandLine, __) => { // Someone tried to run a second instance if (typeof commandLine !== 'undefined') { const newStartupUrl = getLinkUrl(commandLine) if (!(mainWindow && mainWindow.webContents)) { startupUrl = newStartupUrl if (app.isReady()) await createWindow() return } if (commandLine.includes('--new-window')) { // The user wants to create a new window in the existing instance if (newStartupUrl) startupUrl = newStartupUrl await createWindow({ showWindowNow: true, replaceMainWindow: true, }) return } const openDeepLinksInNewWindow = (await baseHandlers.settings._findOne('openDeepLinksInNewWindow'))?.value if (!openDeepLinksInNewWindow) { // Just focus the main window (instead of starting a new instance) if (mainWindow.isMinimized()) { if (process.platform !== 'darwin' && trayOnMinimize) { trayClick(mainWindow) } else { mainWindow.restore() } } mainWindow.focus() if (newStartupUrl) mainWindow.webContents.send(IpcChannels.OPEN_URL, newStartupUrl) return } const newWindow = await createWindow({ replaceMainWindow: false, showWindowNow: true, }) /** * @param {import('electron').IpcMainEvent} event */ const readyHandler = (event) => { if (isFreeTubeUrl(event.senderFrame.url)) { newWindow.webContents.ipc.off(IpcChannels.APP_READY, readyHandler) event.reply(IpcChannels.OPEN_URL, newStartupUrl) } } newWindow.webContents.ipc.on(IpcChannels.APP_READY, readyHandler) } }) } let proxyUrl app.on('ready', async (_, __) => { if (process.platform === 'darwin') { const dockMenu = Menu.buildFromTemplate([ { label: 'New Window', click: () => { createWindow({ replaceMainWindow: false, showWindowNow: true }) } } ]) app.dock.setMenu(dockMenu) } if (process.env.NODE_ENV === 'production') { protocol.handle('app', async (request) => { if (request.method !== 'GET') { return new Response(null, { status: 405, headers: { Allow: 'GET' } }) } const { host, pathname } = new URL(request.url) if (host !== 'bundle' || !ALLOWED_RENDERER_FILES.has(pathname)) { return new Response(null, { status: 400 }) } const contents = await asyncFs.readFile(path.join(__dirname, pathname)) if (pathname.endsWith('.json.br')) { const decompressed = await brotliDecompressAsync(contents) return new Response(decompressed.buffer, { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'br' } }) } else { return new Response(contents.buffer, { status: 200, headers: { 'Content-Type': contentTypeFromFileExtension(pathname.split('.').at(-1)) } }) } }) } // Electron defaults to approving all permission checks and permission requests. // FreeTube only needs a few permissions, so we reject requests for other permissions // and reject all requests on non-FreeTube URLs. // // FreeTube needs the following permissions: // - "fullscreen": So that the video player can enter full screen // - "clipboard-sanitized-write": To allow the user to copy video URLs and error messages // - "fileSystem" Needed for the Web File System API (e.g. importing and exporting data) session.defaultSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { if (!isFreeTubeUrl(requestingOrigin)) { return false } return ( permission === 'fullscreen' || permission === 'clipboard-sanitized-write' || (permission === 'fileSystem' && !details.isDirectory) ) }) session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => { if (!isFreeTubeUrl(webContents.getURL())) { // eslint-disable-next-line n/no-callback-literal callback(false) return } callback( permission === 'fullscreen' || permission === 'clipboard-sanitized-write' || (permission === 'fileSystem' && !details.isDirectory) ) }) session.defaultSession.on('file-system-access-restricted', (event, details, callback) => { if (!isFreeTubeUrl(details.origin)) { // eslint-disable-next-line n/no-callback-literal callback('deny') return } // eslint-disable-next-line n/no-callback-literal callback(details.isDirectory ? 'deny' : 'allow') }) let docArray try { docArray = await baseHandlers.settings._findAppReadyRelatedSettings() } catch (err) { console.error(err) app.exit() return } let disableSmoothScrolling = false let useProxy = false let proxyProtocol = 'socks5' let proxyHostname = '127.0.0.1' let proxyPort = '9050' if (docArray?.length > 0) { docArray.forEach((doc) => { switch (doc._id) { case 'disableSmoothScrolling': disableSmoothScrolling = doc.value break case 'useProxy': useProxy = doc.value break case 'proxyProtocol': proxyProtocol = doc.value break case 'proxyHostname': proxyHostname = doc.value break case 'proxyPort': proxyPort = doc.value break case 'backendFallback': backendFallback = doc.value break case 'backendPreference': backendPreference = doc.value break case 'hideToTrayOnMinimize': if (process.platform !== 'darwin') { trayOnMinimize = doc.value } break } }) } if (disableSmoothScrolling) { app.commandLine.appendSwitch('disable-smooth-scrolling') } else { app.commandLine.appendSwitch('enable-smooth-scrolling') } if (useProxy) { proxyUrl = `${proxyProtocol}://${proxyHostname}:${proxyPort}` session.defaultSession.setProxy({ proxyRules: proxyUrl }) } const fixedUserAgent = session.defaultSession.getUserAgent() .split(' ') .filter(part => !part.includes('Electron') && !part.includes(packageDetails.productName)) .join(' ') session.defaultSession.setUserAgent(fixedUserAgent) // Set CONSENT cookie on reasonable domains const consentCookieDomains = [ 'https://www.youtube.com', 'https://youtube.com' ] consentCookieDomains.forEach(url => { session.defaultSession.cookies.set({ url: url, name: 'CONSENT', value: 'YES+', sameSite: 'no_restriction' }) }) session.defaultSession.cookies.set({ url: 'https://www.youtube.com', name: 'SOCS', value: 'CAI', sameSite: 'no_restriction', }) const onBeforeSendHeadersRequestFilter = { urls: ['https://*/*', 'http://*/*'], types: ['xhr', 'media', 'image'] } session.defaultSession.webRequest.onBeforeSendHeaders(onBeforeSendHeadersRequestFilter, ({ requestHeaders, url, webContents }, callback) => { const urlObj = new URL(url) if (url.startsWith('https://www.youtube.com/youtubei/')) { // make InnerTube requests work with the fetch function // InnerTube rejects requests if the referer isn't YouTube or empty requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' requestHeaders['Sec-Fetch-Site'] = 'same-origin' requestHeaders['Sec-Fetch-Mode'] = 'same-origin' requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' } else if (url === 'https://www.youtube.com/sw.js_data' || url.startsWith('https://www.youtube.com/api/timedtext')) { requestHeaders.Referer = 'https://www.youtube.com/sw.js' requestHeaders['Sec-Fetch-Site'] = 'same-origin' requestHeaders['Sec-Fetch-Mode'] = 'same-origin' } else if ( urlObj.origin.endsWith('.googleusercontent.com') || urlObj.origin.endsWith('.ggpht.com') || urlObj.origin.endsWith('.ytimg.com') ) { requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' } else if (urlObj.origin.endsWith('.googlevideo.com') && urlObj.pathname === '/videoplayback') { requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' // YouTube doesn't send the Content-Type header for the media requests, so we shouldn't either delete requestHeaders['Content-Type'] } else if (urlObj.origin === 'https://ipwho.is') { // Fix the CORS error with the proxy test button requestHeaders = {} } else if (webContents) { const invidiousAuthorization = invidiousAuthorizations.get(webContents.id) if (invidiousAuthorization && url.startsWith(invidiousAuthorization.url)) { requestHeaders.Authorization = invidiousAuthorization.authorization } } callback({ requestHeaders }) }) // when we create a real session on the watch page, youtube returns tracking cookies, which we definitely don't want const trackingCookieRequestFilter = { urls: ['https://www.youtube.com/sw.js_data', 'https://www.youtube.com/iframe_api'] } session.defaultSession.webRequest.onHeadersReceived(trackingCookieRequestFilter, ({ responseHeaders }, callback) => { if (responseHeaders) { delete responseHeaders['set-cookie'] } callback({ responseHeaders }) }) if (replaceHttpCache) { // in-memory image cache const imageCache = new ImageCache() protocol.handle('imagecache', (request) => { const [requestUrl, rawWebContentsId] = request.url.split('#') return new Promise((resolve, reject) => { const url = decodeURIComponent(requestUrl.substring(13)) if (imageCache.has(url)) { const cached = imageCache.get(url) resolve(new Response(cached.data, { headers: { 'content-type': cached.mimeType } })) return } let headers if (rawWebContentsId) { const invidiousAuthorization = invidiousAuthorizations.get(parseInt(rawWebContentsId)) if (invidiousAuthorization && url.startsWith(invidiousAuthorization.url)) { headers = { Authorization: invidiousAuthorization.authorization } } } const newRequest = net.request({ method: request.method, url, headers }) // Electron doesn't allow certain headers to be set: // https://www.electronjs.org/docs/latest/api/client-request#requestsetheadername-value // also blacklist Origin and Referrer as we don't want to let YouTube know about them const blacklistedHeaders = ['content-length', 'host', 'trailer', 'te', 'upgrade', 'cookie2', 'keep-alive', 'transfer-encoding', 'origin', 'referrer'] for (const header of Object.keys(request.headers)) { if (!blacklistedHeaders.includes(header.toLowerCase())) { newRequest.setHeader(header, request.headers[header]) } } newRequest.on('response', (response) => { const chunks = [] response.on('data', (chunk) => { chunks.push(chunk) }) response.on('end', () => { const data = Buffer.concat(chunks) const expiryTimestamp = extractExpiryTimestamp(response.headers) const mimeType = response.headers['content-type'] imageCache.add(url, mimeType, data, expiryTimestamp) resolve(new Response(data, { headers: { 'content-type': mimeType } })) }) response.on('error', (error) => { console.error('image cache error', error) reject(error) }) }) newRequest.on('error', (err) => { console.error(err) }) newRequest.end() }) }) const imageRequestFilter = { urls: ['https://*/*', 'http://*/*'], types: ['image'] } session.defaultSession.webRequest.onBeforeRequest(imageRequestFilter, (details, callback) => { // the requests made by the imagecache:// handler to fetch the image, // are allowed through, as their resourceType is 'other' let redirectURL = `imagecache://${encodeURIComponent(details.url)}` if (details.webContents) { redirectURL += `#${details.webContents.id}` } callback({ redirectURL }) }) // --- end of `if experimentsDisableDiskCache` --- } await createWindow() if (isDebug) { mainWindow.webContents.openDevTools() } }) app.on('login', async (event, webContents, request, authInfo, callback) => { if (authInfo.isProxy) { event.preventDefault() const proxyUsername = (await baseHandlers.settings._findOne('proxyUsername'))?.value const proxyPassword = (await baseHandlers.settings._findOne('proxyPassword'))?.value callback(proxyUsername, proxyPassword) } }) function trayClick(window, close = false) { if (!close) { if (window.id in trayMaximizedWindows) { window.maximize() } else { window.show() // Calling hide() inside minimize is broken for some Linux distros (window minimizes again when trying to drag, // resize or maximize it, among other shenanigans). It seems to work as intended with this workaround. if (process.platform === 'linux') { window.hide() window.show() } } if (trayWindows.length === BrowserWindow.getAllWindows().length) { mainWindow = window } } else if (trayWindows.length > 0) { window.close() } trayWindows.splice(trayWindows.findIndex(item => item.id === window.id), 1) if (trayWindows.length > 0) { createTrayContextMenu() } else { destroyTray() } } function createTrayContextMenu() { const menuItems = [] trayWindows.forEach(window => { menuItems.push({ label: window.title, submenu: [ { label: 'Show', click: () => trayClick(window) }, { label: 'Close', click: () => trayClick(window, true) } ] }) }) menuItems.push( { type: 'separator' }, ...defaultTrayMenu() ) const menu = Menu.buildFromTemplate(menuItems) tray.setContextMenu(menu) } function defaultTrayMenu() { return [ { label: 'New Window', click: () => createWindow({ showWindowNow: true, replaceMainWindow: trayWindows.some(item => item.id === mainWindow.id) }) }, { label: 'Show All Windows', click: () => { // Use while loop instead of for loop as trayClick modifies the trayWindows array while (trayWindows.length > 0) { trayClick(trayWindows[0]) } } }, { label: 'Quit', click: handleQuit } ] } function destroyTray() { if (!tray) return if (process.platform !== 'linux') { tray.destroy() tray = null } else { const menu = Menu.buildFromTemplate(defaultTrayMenu()) tray.setContextMenu(menu) } } function showHiddenWindows() { trayWindows.forEach(window => { window.minimize() }) destroyTray() trayWindows = [] } /** * @param {string} extension */ function contentTypeFromFileExtension(extension) { switch (extension) { case 'html': return 'text/html' case 'css': return 'text/css' case 'js': return 'text/javascript' case 'ttf': return 'font/ttf' case 'woff2': return 'font/woff2' case 'svg': return 'image/svg+xml' case 'png': return 'image/png' case 'json': return 'application/json' case 'txt': return 'text/plain' default: return 'application/octet-stream' } } const htmlFullscreenWindowIds = new Set() async function createWindow( { replaceMainWindow = true, windowStartupUrl = null, showWindowNow = false, searchQueryText = null } = { }) { // Syncing new window background to theme choice. const windowBackground = await baseHandlers.settings._findOne('baseTheme').then((setting) => { if (!setting) { return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' } // Determine window color to be shown (shown most prominently during initial app load) // Uses the --bg-color for each corresponding theme switch (setting.value) { case 'dark': return '#212121' case 'light': return '#f1f1f1' case 'black': return '#000000' case 'dracula': return '#282a36' case 'catppuccin-mocha': return '#1e1e2e' case 'pastel-pink': return '#ffd1dc' case 'hot-pink': return '#de1c85' case 'nordic': return '#2b2f3a' case 'solarized-dark': return '#002B36' case 'solarized-light': return '#fdf6e3' case 'gruvbox-dark': return '#282828' case 'gruvbox-light': return '#fbf1c7' case 'catppuccin-frappe': return '#303446' case 'everforest-dark-hard': return '#272e33' case 'everforest-dark-medium': return '#2d353b' case 'everforest-dark-low': return '#333c43' case 'everforest-light-hard': return '#fffbef' case 'everforest-light-medium': return '#fdf6e3' case 'everforest-light-low': return '#f3ead3' case 'catppuccin-latte': return '#eff1f5' case 'system': default: return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' } }).catch((error) => { console.error(error) // Default to nativeTheme settings if nothing is found. return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' }) let savedBounds, savedMaximized, savedFullScreen const boundsDoc = await baseHandlers.settings._findOne('bounds') if (typeof boundsDoc?.value === 'object') { const { maximized, fullScreen, ...bounds } = boundsDoc.value const windowVisible = screen.getAllDisplays().some(display => { const { x, y, width, height } = display.bounds return !(bounds.x > x + width || bounds.x + bounds.width < x || bounds.y > y + height || bounds.y + bounds.height < y) }) if (windowVisible) { savedBounds = bounds } savedMaximized = maximized savedFullScreen = fullScreen } const newWindow = new BrowserWindow({ // It will be shown later when ready via `ready-to-show` event show: showWindowNow, backgroundColor: windowBackground, darkTheme: nativeTheme.shouldUseDarkColors, icon: process.env.NODE_ENV === 'development' ? path.join(__dirname, '../../_icons/iconColor.png') : path.join(__dirname, '../_icons/iconColor.png'), autoHideMenuBar: true, // useContentSize: true, webPreferences: { webSecurity: false, backgroundThrottling: false, preload: process.env.NODE_ENV === 'development' ? path.resolve(__dirname, '../../dist/preload.js') : path.resolve(__dirname, 'preload.js') }, minWidth: 340, minHeight: 380, ...savedBounds ? { x: savedBounds.x, y: savedBounds.y, width: savedBounds.width, height: savedBounds.height } : { width: 1200, height: 800 } }) // region Ensure child windows use same options since electron 14 // https://github.com/electron/electron/blob/14-x-y/docs/api/window-open.md#native-window-example newWindow.webContents.setWindowOpenHandler((details) => { const url = URL.parse(details.url) // Only handle valid URLs that came from a FreeTube page if (url !== null && isFreeTubeUrl(newWindow.webContents.getURL())) { if (isFreeTubeUrl(url)) { createWindow({ replaceMainWindow: false, showWindowNow: true, windowStartupUrl: details.url }) } else if ( url.protocol === 'http:' || url.protocol === 'https:' || // Email address on the about page and Autolinker detects and links email addresses url.protocol === 'mailto:' || // Autolinker detects and links phone numbers url.protocol === 'tel:' || // Donation links on the about page (url.protocol === 'bitcoin:' && url.pathname === ABOUT_BITCOIN_ADDRESS) ) { shell.openExternal(details.url) } } return { action: 'deny' } }) // endregion Ensure child windows use same options since electron 14 if (process.platform !== 'darwin') { function manageTray(window, removeWindow = false) { if (tray) { if (!removeWindow) { trayWindows.push(window) createTrayContextMenu() } else if (trayWindows.some(item => item.id === window.id)) { trayClick(window) } } else { const icon = process.env.NODE_ENV === 'development' ? path.join(__dirname, '..', '..', '_icons', 'iconColor.png') : path.join(__dirname, '..', '_icons', 'iconColor.png') tray = new Tray(icon) tray.setIgnoreDoubleClickEvents(true) tray.setToolTip('FreeTube') trayWindows = [window] createTrayContextMenu() if (process.platform !== 'linux') { tray.on('click', (event) => { if (trayWindows.length === 1) { trayClick(trayWindows[0]) } }) } } } newWindow.on('minimize', () => { if (trayOnMinimize) { // Workaround for https://github.com/electron/electron/issues/49253 if (process.platform === 'linux') { setTimeout(() => { newWindow.restore() newWindow.hide() }, 100) } else { newWindow.hide() } manageTray(newWindow) if (newWindow === mainWindow) { // A timer is needed because getFocusedWindow doesn't update until the minimize event ends setTimeout(() => { const newMainWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows().find(window => window.isVisible()) if (newMainWindow) { mainWindow = newMainWindow } }, 100) } } }) newWindow.on('maximize', () => { if (trayOnMinimize) { trayMaximizedWindows[newWindow.id] = true } }) newWindow.on('unmaximize', () => { if (trayOnMinimize) { delete trayMaximizedWindows[newWindow.id] } }) } if (replaceMainWindow) { mainWindow = newWindow } if (savedMaximized) { newWindow.maximize() } if (savedFullScreen) { newWindow.setFullScreen(true) } // If called multiple times // Duplicate menu items will be added if (replaceMainWindow) { setMenu() } // load root file/url if (windowStartupUrl != null) { newWindow.loadURL(windowStartupUrl) } else { newWindow.loadURL(ROOT_APP_URL) } if (typeof searchQueryText === 'string' && searchQueryText.length > 0) { /** * @param {import('electron').IpcMainEvent} event */ const searchInputReadyHandler = (event) => { if (isFreeTubeUrl(event.senderFrame.url)) { newWindow.webContents.ipc.off(IpcChannels.SEARCH_INPUT_HANDLING_READY, searchInputReadyHandler) event.reply(IpcChannels.UPDATE_SEARCH_INPUT_TEXT, searchQueryText) } } newWindow.webContents.ipc.on(IpcChannels.SEARCH_INPUT_HANDLING_READY, searchInputReadyHandler) } const showWindow = () => { if (newWindow.isVisible()) { // only open the dev tools if they aren't already open if (process.env.NODE_ENV === 'development' && !newWindow.webContents.isDevToolsOpened()) { newWindow.webContents.openDevTools({ activate: false }) } return } if (process.platform !== 'darwin' && trayOnMinimize && trayWindows.length > 0) { trayClick(newWindow) } else { newWindow.show() newWindow.focus() } if (process.env.NODE_ENV === 'development') { newWindow.webContents.openDevTools({ activate: false }) } } // The `ready-to-show` event doesn't always fire on wayland. // Use the `did-finish-load` event on the web contents instead as that is similar enough // https://github.com/electron/electron/issues/48859 if (process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') === 'wayland') { newWindow.webContents.once('did-finish-load', showWindow) } else { // Show when loaded newWindow.once('ready-to-show', showWindow) } newWindow.on('enter-html-full-screen', () => { htmlFullscreenWindowIds.add(newWindow.id) }) newWindow.on('leave-html-full-screen', () => { htmlFullscreenWindowIds.delete(newWindow.id) }) newWindow.once('close', async () => { // returns true if the element existed in the set const htmlFullscreen = htmlFullscreenWindowIds.delete(newWindow.id) if (BrowserWindow.getAllWindows().length !== 1) { return } const value = { ...newWindow.getNormalBounds(), maximized: newWindow.isMaximized(), // Don't save the full screen state if it was triggered by an HTML API e.g. the video player fullScreen: newWindow.isFullScreen() && !htmlFullscreen } await baseHandlers.settings._updateBounds(value) }) newWindow.once('closed', () => { const allWindows = BrowserWindow.getAllWindows() if (allWindows.length !== 0 && newWindow === mainWindow) { // Replace mainWindow to avoid accessing `mainWindow.webContents` // Which raises "Object has been destroyed" error mainWindow = allWindows[0] } stopPowerSaveBlockerForWindow(newWindow) }) return newWindow } ipcMain.on(IpcChannels.APP_READY, (event) => { if (isFreeTubeUrl(event.senderFrame.url)) { if (startupUrl) { mainWindow.webContents.send(IpcChannels.OPEN_URL, startupUrl) } startupUrl = null } }) ipcMain.on(IpcChannels.SET_WINDOW_TITLE, (event, title) => { if (isFreeTubeUrl(event.senderFrame.url) && typeof title === 'string') { BrowserWindow.fromWebContents(event.sender)?.setTitle(title) } }) function relaunch() { if (process.env.NODE_ENV === 'development') { app.exit(parseInt(process.env.FREETUBE_RELAUNCH_EXIT_CODE)) return } // The AppImage and Windows portable formats must be accounted for // because `process.execPath` points at the temporarily extracted // executables, not the executables themselves // // It's possible to detect these formats and identify their // executables' paths by checking the environmental variables const { env: { APPIMAGE, PORTABLE_EXECUTABLE_FILE } } = process if (!APPIMAGE) { // If it's a Windows portable, PORTABLE_EXECUTABLE_FILE will // hold a value. // Otherwise, `process.execPath` should be used instead. app.relaunch({ args: process.argv.slice(1), execPath: PORTABLE_EXECUTABLE_FILE || process.execPath }) } else { // If it's an AppImage, things must be done the "hard way" // `app.relaunch` doesn't work because of FUSE limitations // Spawn a new process using the APPIMAGE env variable const subprocess = cp.spawn(APPIMAGE, { detached: true, stdio: 'ignore' }) subprocess.unref() } app.quit() } ipcMain.once(IpcChannels.RELAUNCH_REQUEST, () => { relaunch() }) nativeTheme.on('updated', () => { const allWindows = BrowserWindow.getAllWindows() allWindows.forEach((window) => { if (isFreeTubeUrl(window.webContents.getURL())) { window.webContents.send(IpcChannels.NATIVE_THEME_UPDATE, nativeTheme.shouldUseDarkColors) } }) }) ipcMain.handle(IpcChannels.GENERATE_PO_TOKEN, (event, videoId, context) => { if (isFreeTubeUrl(event.senderFrame.url)) { return generatePoToken(videoId, context, proxyUrl) } }) ipcMain.on(IpcChannels.ENABLE_PROXY, (event, url) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } session.defaultSession.setProxy({ proxyRules: url }) proxyUrl = url session.defaultSession.closeAllConnections() }) ipcMain.on(IpcChannels.DISABLE_PROXY, (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } session.defaultSession.setProxy({}) proxyUrl = undefined session.defaultSession.closeAllConnections() }) // #region navigation history const NAV_HISTORY_DISPLAY_LIMIT = 15 // Math.trunc but with a bitwise OR so that it can be calcuated at build time and the number inlined const HALF_OF_NAV_HISTORY_DISPLAY_LIMIT = (NAV_HISTORY_DISPLAY_LIMIT / 2) | 0 ipcMain.handle(IpcChannels.GET_NAVIGATION_HISTORY, ({ senderFrame, sender }) => { if (!isFreeTubeUrl(senderFrame.url)) { return } const activeIndex = sender.navigationHistory.getActiveIndex() const length = sender.navigationHistory.length() let end if (activeIndex < HALF_OF_NAV_HISTORY_DISPLAY_LIMIT) { end = Math.min(length - 1, NAV_HISTORY_DISPLAY_LIMIT - 1) } else if (length - activeIndex < HALF_OF_NAV_HISTORY_DISPLAY_LIMIT + 1) { end = length - 1 } else { end = activeIndex + HALF_OF_NAV_HISTORY_DISPLAY_LIMIT } const dropdownOptions = [] for (let index = end; index >= Math.max(0, end + 1 - NAV_HISTORY_DISPLAY_LIMIT); --index) { const routeLabel = sender.navigationHistory.getEntryAtIndex(index)?.title dropdownOptions.push({ label: routeLabel, value: index - activeIndex, active: index === activeIndex }) } return dropdownOptions }) // #endregion navigation history ipcMain.handle(IpcChannels.GET_SYSTEM_LOCALE, (event) => { if (isFreeTubeUrl(event.senderFrame.url)) { // we should switch to getPreferredSystemLanguages at some point and iterate through until we find a supported locale return app.getSystemLocale() } }) /** * @param {import('electron').WebContents} webContents * @param {string | undefined} [currentPath] */ async function chooseDefaultFolder(webContents, currentPath) { if (typeof currentPath !== 'string' || currentPath.length === 0) { currentPath = app.getPath('pictures') } const dialogOptions = { defaultPath: currentPath, properties: ['openDirectory'] } let result const window = BrowserWindow.fromWebContents(webContents) if (window) { result = await dialog.showOpenDialog(window, dialogOptions) } else { result = await dialog.showOpenDialog(dialogOptions) } if (result.canceled) { return } const settingId = 'screenshotFolderPath' await baseHandlers.settings.upsert(settingId, result.filePaths[0]) const syncPayload = { event: SyncEvents.GENERAL.UPSERT, data: { _id: settingId, value: result.filePaths[0] } } BrowserWindow.getAllWindows().forEach((window) => { if (isFreeTubeUrl(window.webContents.getURL())) { window.webContents.send(IpcChannels.SYNC_SETTINGS, syncPayload) } }) return result.filePaths[0] } ipcMain.on(IpcChannels.CHOOSE_DEFAULT_FOLDER, async (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } const currentPath = (await baseHandlers.settings._findOne('screenshotFolderPath'))?.value await chooseDefaultFolder(event.sender, currentPath) }) ipcMain.handle(IpcChannels.WRITE_TO_DEFAULT_FOLDER, async (event, filename, arrayBuffer) => { if ( !isFreeTubeUrl(event.senderFrame.url) || typeof filename !== 'string' || !(arrayBuffer instanceof ArrayBuffer)) { return } const folderPath = (await baseHandlers.settings._findOne('screenshotFolderPath'))?.value let directory if (typeof folderPath === 'string' && folderPath.length > 0) { try { await asyncFs.access(path.normalize(folderPath), fsConstants.W_OK) directory = folderPath } catch {} } // if setting is not set or we do not have write access to the folder // prompt the user for a folder // not having write access can happen if the user copies their settings to different machines // or if they revoke a previously permitted folder in flatseal if (directory === undefined) { directory = await chooseDefaultFolder(event.sender) if (typeof directory !== 'string' || directory.length === 0) { return false } } directory = path.normalize(directory) const filePath = path.resolve(directory, filename) // Ensure that we are only writing inside of the expected directory if (path.dirname(filePath) !== directory) { throw new Error('Invalid save location') } try { await asyncFs.mkdir(directory, { recursive: true }) await asyncFs.writeFile(filePath, new DataView(arrayBuffer)) } catch (error) { console.error('WRITE_TO_DEFAULT_FOLDER failed', error) // throw a new error so that we don't expose the real error to the renderer // eslint-disable-next-line preserve-caught-error throw new Error('Failed to save') } return true }) /** @type {Map} */ const activePowerSaveBlockers = new Map() /** * @param {BrowserWindow} window */ function stopPowerSaveBlockerForWindow(window) { const powerSaveBlockerId = activePowerSaveBlockers.get(window.id) if (typeof powerSaveBlockerId === 'number') { powerSaveBlocker.stop(powerSaveBlockerId) activePowerSaveBlockers.delete(window.id) } } ipcMain.on(IpcChannels.STOP_POWER_SAVE_BLOCKER, (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } const browserWindow = BrowserWindow.fromWebContents(event.sender) if (browserWindow) { stopPowerSaveBlockerForWindow(browserWindow) } }) ipcMain.on(IpcChannels.START_POWER_SAVE_BLOCKER, (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } const browserWindow = BrowserWindow.fromWebContents(event.sender) if (browserWindow && !activePowerSaveBlockers.has(browserWindow.id)) { const powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep') activePowerSaveBlockers.set(browserWindow.id, powerSaveBlockerId) } }) ipcMain.on(IpcChannels.CREATE_NEW_WINDOW, (event, path, query, searchQueryText) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } if ( typeof path !== 'string' || (query != null && typeof query !== 'object') || (searchQueryText != null && typeof searchQueryText !== 'string') ) { return } if (path.charAt(0) !== '/') { path = `/${path}` } let windowStartupUrl = `${ROOT_APP_URL}#${path}` if (query) { windowStartupUrl += '?' + new URLSearchParams(query).toString() } createWindow({ replaceMainWindow: false, showWindowNow: true, windowStartupUrl, searchQueryText }) }) ipcMain.on(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, handleOpenInExternalPlayer) ipcMain.handle(IpcChannels.GET_REPLACE_HTTP_CACHE, (event) => { if (isFreeTubeUrl(event.senderFrame.url)) { return replaceHttpCache } }) ipcMain.once(IpcChannels.TOGGLE_REPLACE_HTTP_CACHE, async (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } if (replaceHttpCache) { await asyncFs.rm(REPLACE_HTTP_CACHE_PATH) } else { // create an empty file const handle = await asyncFs.open(REPLACE_HTTP_CACHE_PATH, 'w') await handle.close() } relaunch() }) function playerCachePathForKey(key) { // Remove path separators and period characters, // to prevent any files outside of the player_cache directory, // from being read or written const sanitizedKey = `${key}`.replaceAll(/[./\\]/g, '__') return path.join(PLAYER_CACHE_PATH, sanitizedKey) } ipcMain.handle(IpcChannels.PLAYER_CACHE_GET, async (event, key) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } const filePath = playerCachePathForKey(key) try { const contents = await asyncFs.readFile(filePath) return contents.buffer } catch (e) { // Don't log the error if the file doesn't exist as we'll just fetch it from YouTube // this usually happens when YouTube updates their player JavaScript if (e.code !== 'ENOENT') { console.error(e) } return undefined } }) ipcMain.handle(IpcChannels.PLAYER_CACHE_SET, async (event, key, value) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } const filePath = playerCachePathForKey(key) await asyncFs.mkdir(PLAYER_CACHE_PATH, { recursive: true }) await asyncFs.writeFile(filePath, new Uint8Array(value)) }) /** @type {Map} */ const invidiousAuthorizations = new Map() ipcMain.on(IpcChannels.SET_INVIDIOUS_AUTHORIZATION, (event, authorization, url) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } if (!authorization) { invidiousAuthorizations.delete(event.sender.id) } else if (typeof authorization === 'string' && typeof url === 'string') { invidiousAuthorizations.set(event.sender.id, { authorization, url }) } }) // ************************************************* // // DB related IPC calls // *********** // // Settings ipcMain.handle(IpcChannels.DB_SETTINGS, async (event, { action, data }) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } try { switch (action) { case DBActions.GENERAL.FIND: return await baseHandlers.settings.find() case DBActions.GENERAL.UPSERT: // This one is only allowed to be changed by the CHOOSE_DEFAULT_FOLDER IPC action // to avoid the "write to default folder" IPC calls being abused to write to arbitrary locations if (data._id === 'screenshotFolderPath') { return null } await baseHandlers.settings.upsert(data._id, data.value) syncOtherWindows( IpcChannels.SYNC_SETTINGS, event, { event: SyncEvents.GENERAL.UPSERT, data } ) switch (data._id) { // Update app menu on related setting update case 'backendFallback': backendFallback = data.value await setMenu() break case 'backendPreference': backendPreference = data.value await setMenu() break case 'hideTrendingVideos': case 'hidePopularVideos': case 'hidePlaylists': await setMenu() break case 'hideToTrayOnMinimize': if (process.platform !== 'darwin') { trayOnMinimize = data.value if (!trayOnMinimize) { showHiddenWindows() } } break default: // Do nothing for unmatched settings } return null default: // eslint-disable-next-line no-throw-literal throw 'invalid settings db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // // History ipcMain.handle(IpcChannels.DB_HISTORY, async (event, { action, data }) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } try { switch (action) { case DBActions.GENERAL.FIND: return await baseHandlers.history.find() case DBActions.GENERAL.UPSERT: await baseHandlers.history.upsert(data) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.GENERAL.UPSERT, data } ) return null case DBActions.GENERAL.OVERWRITE: await baseHandlers.history.overwrite(data) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.GENERAL.OVERWRITE, data } ) return null case DBActions.HISTORY.UPDATE_WATCH_PROGRESS: await baseHandlers.history.updateWatchProgress(data.videoId, data.watchProgress) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.HISTORY.UPDATE_WATCH_PROGRESS, data } ) return null case DBActions.HISTORY.UPDATE_PLAYLIST: await baseHandlers.history.updateLastViewedPlaylist(data.videoId, data.lastViewedPlaylistId, data.lastViewedPlaylistType, data.lastViewedPlaylistItemId) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.HISTORY.UPDATE_PLAYLIST, data } ) return null case DBActions.GENERAL.DELETE: await baseHandlers.history.delete(data) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.GENERAL.DELETE, data } ) return null case DBActions.GENERAL.DELETE_ALL: await baseHandlers.history.deleteAll() syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.GENERAL.DELETE_ALL } ) return null default: // eslint-disable-next-line no-throw-literal throw 'invalid history db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // // Profiles ipcMain.handle(IpcChannels.DB_PROFILES, async (event, { action, data }) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } try { switch (action) { case DBActions.GENERAL.CREATE: { const newProfile = await baseHandlers.profiles.create(data) syncOtherWindows( IpcChannels.SYNC_PROFILES, event, { event: SyncEvents.GENERAL.CREATE, data: newProfile } ) return newProfile } case DBActions.GENERAL.FIND: return await baseHandlers.profiles.find() case DBActions.GENERAL.UPSERT: await baseHandlers.profiles.upsert(data) syncOtherWindows( IpcChannels.SYNC_PROFILES, event, { event: SyncEvents.GENERAL.UPSERT, data } ) return null case DBActions.PROFILES.ADD_CHANNEL: await baseHandlers.profiles.addChannelToProfiles(data.channel, data.profileIds) syncOtherWindows( IpcChannels.SYNC_PROFILES, event, { event: SyncEvents.PROFILES.ADD_CHANNEL, data } ) return null case DBActions.PROFILES.REMOVE_CHANNEL: await baseHandlers.profiles.removeChannelFromProfiles(data.channelId, data.profileIds) syncOtherWindows( IpcChannels.SYNC_PROFILES, event, { event: SyncEvents.PROFILES.REMOVE_CHANNEL, data } ) return null case DBActions.GENERAL.DELETE: await baseHandlers.profiles.delete(data) syncOtherWindows( IpcChannels.SYNC_PROFILES, event, { event: SyncEvents.GENERAL.DELETE, data } ) return null default: // eslint-disable-next-line no-throw-literal throw 'invalid profile db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // // Playlists // ! NOTE: A lot of these actions are currently not used for anything // As such, only the currently used actions have synchronization implemented // The remaining should have it implemented only when playlists // get fully implemented into the app ipcMain.handle(IpcChannels.DB_PLAYLISTS, async (event, { action, data }) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } try { switch (action) { case DBActions.GENERAL.CREATE: await baseHandlers.playlists.create(data) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.GENERAL.CREATE, data } ) return null case DBActions.GENERAL.FIND: return await baseHandlers.playlists.find() case DBActions.GENERAL.UPSERT: await baseHandlers.playlists.upsert(data) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.GENERAL.UPSERT, data } ) return null case DBActions.PLAYLISTS.UPSERT_VIDEO: await baseHandlers.playlists.upsertVideoByPlaylistId(data._id, data.lastUpdatedAt, data.videoData) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.PLAYLISTS.UPSERT_VIDEO, data } ) return null case DBActions.PLAYLISTS.UPSERT_VIDEOS: await baseHandlers.playlists.upsertVideosByPlaylistId(data._id, data.lastUpdatedAt, data.videos) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.PLAYLISTS.UPSERT_VIDEOS, data } ) return null case DBActions.GENERAL.DELETE: await baseHandlers.playlists.delete(data) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.GENERAL.DELETE, data } ) return null case DBActions.PLAYLISTS.DELETE_VIDEO_ID: await baseHandlers.playlists.deleteVideoIdByPlaylistId(data._id, data.lastUpdatedAt, data.videoId, data.playlistItemId) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.PLAYLISTS.DELETE_VIDEO, data } ) return null case DBActions.PLAYLISTS.DELETE_VIDEO_IDS: await baseHandlers.playlists.deleteVideoIdsByPlaylistId(data._id, data.lastUpdatedAt, data.playlistItemIds) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.PLAYLISTS.DELETE_VIDEOS, data } ) return null case DBActions.PLAYLISTS.DELETE_ALL_VIDEOS: await baseHandlers.playlists.deleteAllVideosByPlaylistId(data) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null case DBActions.GENERAL.DELETE_MULTIPLE: await baseHandlers.playlists.deleteMultiple(data) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null case DBActions.GENERAL.DELETE_ALL: await baseHandlers.playlists.deleteAll() // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null default: // eslint-disable-next-line no-throw-literal throw 'invalid playlist db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // // ************** // // Search History ipcMain.handle(IpcChannels.DB_SEARCH_HISTORY, async (event, { action, data }) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } try { switch (action) { case DBActions.GENERAL.FIND: return await baseHandlers.searchHistory.find() case DBActions.GENERAL.UPSERT: await baseHandlers.searchHistory.upsert(data) syncOtherWindows( IpcChannels.SYNC_SEARCH_HISTORY, event, { event: SyncEvents.GENERAL.UPSERT, data } ) return null case DBActions.GENERAL.OVERWRITE: await baseHandlers.searchHistory.overwrite(data) syncOtherWindows( IpcChannels.SYNC_SEARCH_HISTORY, event, { event: SyncEvents.GENERAL.OVERWRITE, data } ) return null case DBActions.GENERAL.DELETE: await baseHandlers.searchHistory.delete(data) syncOtherWindows( IpcChannels.SYNC_SEARCH_HISTORY, event, { event: SyncEvents.GENERAL.DELETE, data } ) return null case DBActions.GENERAL.DELETE_ALL: await baseHandlers.searchHistory.deleteAll() syncOtherWindows( IpcChannels.SYNC_SEARCH_HISTORY, event, { event: SyncEvents.GENERAL.DELETE_ALL } ) return null default: // eslint-disable-next-line no-throw-literal throw 'invalid search history db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // // Profiles ipcMain.handle(IpcChannels.DB_SUBSCRIPTION_CACHE, async (event, { action, data }) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return } try { switch (action) { case DBActions.GENERAL.FIND: return await baseHandlers.subscriptionCache.find() case DBActions.SUBSCRIPTION_CACHE.UPDATE_VIDEOS_BY_CHANNEL: await baseHandlers.subscriptionCache.updateVideosByChannelId(data.channelId, data.entries, data.timestamp) syncOtherWindows( IpcChannels.SYNC_SUBSCRIPTION_CACHE, event, { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_VIDEOS_BY_CHANNEL, data } ) return null case DBActions.SUBSCRIPTION_CACHE.UPDATE_LIVE_STREAMS_BY_CHANNEL: await baseHandlers.subscriptionCache.updateLiveStreamsByChannelId(data.channelId, data.entries, data.timestamp) syncOtherWindows( IpcChannels.SYNC_SUBSCRIPTION_CACHE, event, { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_LIVE_STREAMS_BY_CHANNEL, data } ) return null case DBActions.SUBSCRIPTION_CACHE.UPDATE_SHORTS_BY_CHANNEL: await baseHandlers.subscriptionCache.updateShortsByChannelId(data.channelId, data.entries, data.timestamp) syncOtherWindows( IpcChannels.SYNC_SUBSCRIPTION_CACHE, event, { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_SHORTS_BY_CHANNEL, data } ) return null case DBActions.SUBSCRIPTION_CACHE.UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL: await baseHandlers.subscriptionCache.updateShortsWithChannelPageShortsByChannelId(data.channelId, data.entries) syncOtherWindows( IpcChannels.SYNC_SUBSCRIPTION_CACHE, event, { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL, data } ) return null case DBActions.SUBSCRIPTION_CACHE.UPDATE_COMMUNITY_POSTS_BY_CHANNEL: await baseHandlers.subscriptionCache.updateCommunityPostsByChannelId(data.channelId, data.entries, data.timestamp) syncOtherWindows( IpcChannels.SYNC_SUBSCRIPTION_CACHE, event, { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_COMMUNITY_POSTS_BY_CHANNEL, data } ) return null case DBActions.GENERAL.DELETE_MULTIPLE: await baseHandlers.subscriptionCache.deleteMultipleChannels(data) syncOtherWindows( IpcChannels.SYNC_SUBSCRIPTION_CACHE, event, { event: SyncEvents.GENERAL.DELETE_MULTIPLE, data } ) return null case DBActions.GENERAL.DELETE_ALL: await baseHandlers.subscriptionCache.deleteAll() syncOtherWindows( IpcChannels.SYNC_SUBSCRIPTION_CACHE, event, { event: SyncEvents.GENERAL.DELETE_ALL, data } ) return null default: // eslint-disable-next-line no-throw-literal throw 'invalid subscriptionCache db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // function syncOtherWindows(channel, event, payload) { const otherWindows = BrowserWindow.getAllWindows().filter((window) => { return window.webContents.id !== event.sender.id && isFreeTubeUrl(window.webContents.getURL()) }) for (const window of otherWindows) { window.webContents.send(channel, payload) } } // ************************************************* // let resourcesCleanUpDone = false app.on('window-all-closed', () => { // Clean up resources (datastores' compaction + Electron cache and storage data clearing) handleQuit() }) if (process.platform === 'darwin') { // `window-all-closed` doesn't fire for Cmd+Q // https://www.electronjs.org/docs/latest/api/app#event-window-all-closed // This is also fired when `app.quit` called // Not using `before-quit` since that one is fired before windows are closed app.on('will-quit', e => { // Let app quit when the cleanup is finished if (resourcesCleanUpDone) { return } e.preventDefault() cleanUpResources().finally(() => { // Quit AFTER the resources cleanup is finished // Which calls the listener again, which is why we have the variable app.quit() }) }) } if (process.platform !== 'darwin') { app.on('before-quit', () => { if (tray) { tray.destroy() } }) } function handleQuit() { cleanUpResources().finally(() => { mainWindow = null if (process.platform !== 'darwin') { app.quit() } }) } async function cleanUpResources() { if (resourcesCleanUpDone) { return } await Promise.allSettled([ baseHandlers.compactAllDatastores(), session.defaultSession.clearCache(), session.defaultSession.clearStorageData({ storages: [ 'appcache', 'cookies', 'filesystem', 'indexdb', 'shadercache', 'websql', 'serviceworkers', 'cachestorage' ] }) ]) resourcesCleanUpDone = true } // MacOS event // https://www.electronjs.org/docs/latest/api/app#event-activate-macos app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } }) /* * Callback when processing a freetube:// link (macOS) */ app.on('open-url', async (event, url) => { event.preventDefault() const newStartupUrl = baseUrl(url) if (!(mainWindow && mainWindow.webContents)) { startupUrl = newStartupUrl if (app.isReady()) await createWindow() return } const openDeepLinksInNewWindow = (await baseHandlers.settings._findOne('openDeepLinksInNewWindow'))?.value if (!openDeepLinksInNewWindow) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.focus() mainWindow.webContents.send(IpcChannels.OPEN_URL, newStartupUrl) return } const newWindow = await createWindow({ replaceMainWindow: false, showWindowNow: true, }) /** * @param {import('electron').IpcMainEvent} event */ const readyHandler = (event) => { if (isFreeTubeUrl(event.senderFrame.url)) { newWindow.webContents.ipc.off(IpcChannels.APP_READY, readyHandler) event.reply(IpcChannels.OPEN_URL, newStartupUrl) } } newWindow.webContents.ipc.on(IpcChannels.APP_READY, readyHandler) }) app.on('web-contents-created', (_, webContents) => { webContents.once('destroyed', () => { invidiousAuthorizations.delete(webContents.id) }) }) /* * Check if an argument was passed and send it over to the GUI (Linux / Windows). * Remove freetube:// protocol if present */ const url = getLinkUrl(process.argv) if (url) { startupUrl = url } function baseUrl(arg) { let newArg = arg.replace('freetube://', '') // add support for authority free url .replace('freetube:', '') // fix for Qt URL, like `freetube://https//www.youtube.com/watch?v=...` // For details see https://github.com/FreeTubeApp/FreeTube/pull/3119 if (newArg.startsWith('https') && newArg.charAt(5) !== ':') { newArg = 'https:' + newArg.substring(5) } return newArg } function getLinkUrl(argv) { if (argv.length > 1) { return baseUrl(argv[argv.length - 1]) } else { return null } } /* * Auto Updater * * Uncomment the following code below and install `electron-updater` to * support auto updating. Code Signing with a valid certificate is required. * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-electron-builder.html#auto-updating */ /* import { autoUpdater } from 'electron-updater' autoUpdater.on('update-downloaded', () => { autoUpdater.quitAndInstall() }) app.on('ready', () => { if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdates() }) */ function navigateTo(path, browserWindow) { if (browserWindow == null || !isFreeTubeUrl(browserWindow.webContents.getURL())) { return } browserWindow.webContents.send(IpcChannels.CHANGE_VIEW, path) } async function setMenu() { const sidenavSettings = baseHandlers.settings._findSidenavSettings() const hideTrendingVideos = (await sidenavSettings.hideTrendingVideos)?.value const hidePopularVideos = (await sidenavSettings.hidePopularVideos)?.value const hidePlaylists = (await sidenavSettings.hidePlaylists)?.value const template = [ ...process.platform === 'darwin' ? [ { label: app.getName(), submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideothers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' } ] } ] : [], { label: 'File', submenu: [ { label: 'New Window', accelerator: 'CmdOrCtrl+N', click: (_menuItem, _browserWindow, _event) => { createWindow({ replaceMainWindow: false, showWindowNow: true }) }, type: 'normal' }, { type: 'separator' }, { label: 'Preferences', accelerator: 'CmdOrCtrl+,', click: (_menuItem, browserWindow, _event) => { navigateTo('/settings', browserWindow) }, type: 'normal' }, { type: 'separator' }, { role: 'quit' } ] }, { label: 'Edit', submenu: [ { role: 'cut' }, { role: 'copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' }, { role: 'paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' }, { role: 'pasteandmatchstyle' }, { role: 'delete' }, { role: 'selectall' } ] }, { label: 'View', submenu: [ { role: 'toggledevtools' }, { role: 'toggledevtools', accelerator: 'f12', visible: false }, { label: 'Enter Inspect Element Mode', accelerator: 'CmdOrCtrl+Shift+C', click: (_, window) => { if (window.webContents.isDevToolsOpened()) { window.devToolsWebContents.executeJavaScript('DevToolsAPI.enterInspectElementMode()') } else { window.webContents.once('devtools-opened', () => { window.devToolsWebContents.executeJavaScript('DevToolsAPI.enterInspectElementMode()') }) window.webContents.openDevTools() } } }, { label: 'GPU Internals (chrome://gpu)', click() { const gpuWindow = new BrowserWindow({ show: true, autoHideMenuBar: true, webPreferences: { devTools: false } }) gpuWindow.loadURL('chrome://gpu') } }, { type: 'separator' }, { role: 'resetzoom' }, { role: 'resetzoom', accelerator: 'CmdOrCtrl+num0', visible: false }, { role: 'zoomin', accelerator: 'CmdOrCtrl+Plus' }, { role: 'zoomin', accelerator: 'CmdOrCtrl+=', visible: false }, { role: 'zoomin', accelerator: 'CmdOrCtrl+numadd', visible: false }, { role: 'zoomout' }, { role: 'zoomout', accelerator: 'CmdOrCtrl+numsub', visible: false }, { type: 'separator' }, { role: 'togglefullscreen' }, { type: 'separator' }, { label: 'Back', accelerator: 'Alt+Left', click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } browserWindow.webContents.navigationHistory.goBack() }, type: 'normal', }, ...(process.platform === 'darwin' ? [ { label: 'Back', accelerator: KeyboardShortcuts.APP.GENERAL.HISTORY_BACKWARD_ALT_MAC, click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } browserWindow.webContents.navigationHistory.goBack() }, visible: false, }, ] : []), { label: 'Forward', accelerator: 'Alt+Right', click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } browserWindow.webContents.navigationHistory.goForward() }, type: 'normal', }, ...(process.platform === 'darwin' ? [ { label: 'Forward', accelerator: KeyboardShortcuts.APP.GENERAL.HISTORY_FORWARD_ALT_MAC, click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } browserWindow.webContents.navigationHistory.goForward() }, visible: false, }, ] : []), ] }, { label: 'Navigate', submenu: [ { label: 'Subscriptions', click: (_menuItem, browserWindow, _event) => { navigateTo('/subscriptions', browserWindow) }, type: 'normal' }, { label: 'Channels', click: (_menuItem, browserWindow, _event) => { navigateTo('/subscribedchannels', browserWindow) }, type: 'normal' }, (!hideTrendingVideos && (backendFallback || backendPreference === 'local')) && { label: 'Trending', click: (_menuItem, browserWindow, _event) => { navigateTo('/trending', browserWindow) }, type: 'normal' }, (!hidePopularVideos && (backendFallback || backendPreference === 'invidious')) && { label: 'Most Popular', click: (_menuItem, browserWindow, _event) => { navigateTo('/popular', browserWindow) }, type: 'normal' }, !hidePlaylists && { label: 'Playlists', click: (_menuItem, browserWindow, _event) => { navigateTo('/userplaylists', browserWindow) }, type: 'normal' }, { label: 'History', // MacOS: Command + Y // Other OS: Ctrl + H accelerator: process.platform === 'darwin' ? 'Cmd+Y' : 'Ctrl+H', click: (_menuItem, browserWindow, _event) => { navigateTo('/history', browserWindow) }, type: 'normal' }, { label: 'Profile Manager', click: (_menuItem, browserWindow, _event) => { navigateTo('/settings/profile/', browserWindow) }, type: 'normal' }, ].filter((v) => v !== false), }, { role: 'window', submenu: [ { role: 'minimize' }, { role: 'close' } ] }, ...process.platform === 'darwin' ? [ { role: 'window' }, { role: 'help' }, { role: 'services' } ] : [] ] const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) } } ================================================ FILE: src/main/poTokenGenerator.js ================================================ import { session, WebContentsView } from 'electron' import { readFile } from 'fs/promises' import { join } from 'path' // #region queue /** * This is the internal Promise object which resolves when all the tasks of the queue are done. * It will change any time {@linkcode enqueueAsyncFunction} is called. */ let queueGuardian = Promise.resolve() /** * Enqueues an asynchronous function to be executed after the previous ones in the queue have finished. * That way the promises/asynchronous functions are executed sequentially rather than in parallel. * * @template T * @param {T} func * @param {Parameters} args * @returns {ReturnType} */ function enqueueAsyncFunction(func, ...args) { queueGuardian = queueGuardian.then(() => { return func(...args) .then(result => ({ error: false, result }), result => ({ error: true, result })) }) return queueGuardian.then(({ error, result }) => { if (error) return Promise.reject(result) else return Promise.resolve(result) }) } // #endregion queue let firstTime = true /** * Generates a content-bound poToken (proof of origin token) using `bgutils-js`. * The script to generate it is `src/botGuardScript.js` * * This is intentionally split out into it's own thing, with it's own in-memory session, * as the BotGuard stuff accesses the global `document` and `window` objects and also requires making some requests. * So we definitely don't want it running in the same places as the rest of the FreeTube code with the user data. * @param {string} videoId * @param {string} context * @param {string|undefined} proxyUrl * @returns {Promise} */ export function generatePoToken(videoId, context, proxyUrl) { if (firstTime) { firstTime = false enqueueAsyncFunction(sharedInit) } // We use a promise queue instead of running the `internalGeneratePotoken` function directly // so that we can reuse the same session by clearing all data // associated with the session before triggering generating the next PO token. // Electron's session objects stick around for the entire lifetime of the Electron main process, // holding onto OS resources such as the OS DNS resolver, so if we created a new session for each PO token generation // the OS will eventually complain about the resources being exhausted (e.g. too many inotify instances on Linux) // References // - https://github.com/FreeTubeApp/FreeTube/issues/8640 // - https://github.com/electron/electron/pull/46131 // - https://github.com/electron/electron/commit/bac2f46ba981cc1763c0485cec44813c1d07fa18 const potokenPromise = enqueueAsyncFunction(internalGeneratePotoken, videoId, context, proxyUrl) // schedule the cleanup separately, // so that we can return the potoken without having to wait until the cleanup is done enqueueAsyncFunction(cleanupSession) return potokenPromise } /** @type {import('electron').Session} */ let theSession /** @type {string} */ let cachedScript async function sharedInit() { // setup session theSession = session.fromPartition('potoken', { cache: false }) theSession.setPermissionCheckHandler(() => false) // eslint-disable-next-line n/no-callback-literal theSession.setPermissionRequestHandler((webContents, permission, callback) => callback(false)) theSession.setUserAgent(session.defaultSession.getUserAgent()) theSession.webRequest.onBeforeSendHeaders({ urls: ['https://www.google.com/js/*', 'https://www.youtube.com/youtubei/*'] }, ({ requestHeaders, url }, callback) => { if (url.startsWith('https://www.youtube.com/youtubei/')) { // make InnerTube requests work with the fetch function // InnerTube rejects requests if the referer isn't YouTube or empty requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' requestHeaders['Sec-Fetch-Site'] = 'same-origin' requestHeaders['Sec-Fetch-Mode'] = 'same-origin' requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' } else { requestHeaders['Sec-Fetch-Dest'] = 'script' requestHeaders['Sec-Fetch-Site'] = 'cross-site' requestHeaders['Accept-Language'] = '*' } callback({ requestHeaders }) }) theSession.webRequest.onHeadersReceived({ urls: ['https://*/*'] }, ({ responseHeaders }, callback) => { if (responseHeaders) { callback({ responseHeaders: { ...responseHeaders, 'Access-Control-Allow-Origin': ['*'], 'Access-Control-Allow-Methods': ['GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH'] } }) } }) theSession.webRequest.onBeforeRequest({ urls: [''], types: ['cspReport', 'ping'] }, (details, callback) => { callback({ cancel: true }) }) // load script file const pathToScript = process.env.NODE_ENV === 'development' ? join(__dirname, '../../dist/botGuardScript.js') : join(__dirname, 'botGuardScript.js') const scriptContent = await readFile(pathToScript, 'utf-8') const scriptExportMatch = scriptContent.match(/export{(\w+) as default};/) cachedScript = scriptContent.replace(scriptExportMatch[0], `;${scriptExportMatch[1]}(FT_PARAMS)`) } /** * @param {string} videoId * @param {string} context * @param {string|undefined} proxyUrl * @returns {Promise} */ async function internalGeneratePotoken(videoId, context, proxyUrl) { let webContentsView try { if (proxyUrl) { await theSession.setProxy({ proxyRules: proxyUrl }) } webContentsView = new WebContentsView({ webPreferences: { backgroundThrottling: false, safeDialogs: true, sandbox: true, contextIsolation: true, v8CacheOptions: 'none', session: theSession, offscreen: true, disableBlinkFeatures: 'ElectronCSSCornerSmoothing' } }) webContentsView.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) webContentsView.webContents.setAudioMuted(true) webContentsView.setBounds({ x: 0, y: 0, width: 1920, height: 1080 }) webContentsView.webContents.debugger.attach() await webContentsView.webContents.loadURL('data:text/html,', { baseURLForDataURL: 'https://www.youtube.com/' }) await webContentsView.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', { width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false, screenWidth: 1920, screenHeight: 1080, positionX: 0, positionY: 0, screenOrientation: { type: 'landscapePrimary', angle: 0 } }) const script = cachedScript.replace('FT_PARAMS', `"${videoId}",${context}`) return await webContentsView.webContents.executeJavaScript(script) } finally { if (webContentsView) { webContentsView.webContents.close({ waitForBeforeUnload: false }) } } } async function cleanupSession() { await theSession.closeAllConnections() await theSession.clearData() } ================================================ FILE: src/main/utils.js ================================================ /** * @param {string | URL} url */ export function isFreeTubeUrl(url) { let url_ if (url instanceof URL) { url_ = url } else { url_ = URL.parse(url) } if (process.env.NODE_ENV === 'development') { return url_ !== null && url_.protocol === 'http:' && url_.host === 'localhost:9080' && (url_.pathname === '/' || url_.pathname === '/index.html') } else { return url_ !== null && url_.protocol === 'app:' && url_.host === 'bundle' && (url_.pathname === '/' || url_.pathname === '/index.html') } } ================================================ FILE: src/preload/interface.js ================================================ import { ipcRenderer, webFrame } from 'electron/renderer' import { IpcChannels } from '../constants.js' /** * Linux fix for dynamically updating theme preference, this works on * all systems running the electron app. */ ipcRenderer.on(IpcChannels.NATIVE_THEME_UPDATE, (_, shouldUseDarkColors) => { document.body.dataset.systemTheme = shouldUseDarkColors ? 'dark' : 'light' }) // Force update the window title whenever the page title changes // as Electron doesn't do it when the back button is pressed, probably a bug. // It doesn't even fire the `page-title-updated` in the main process. const titleMutationObserver = new MutationObserver((mutations) => { ipcRenderer.send(IpcChannels.SET_WINDOW_TITLE, mutations[0].addedNodes[0].textContent) }) document.addEventListener('DOMContentLoaded', () => { titleMutationObserver.observe(document.querySelector('title'), { childList: true }) }, { once: true }) let currentUpdateSearchInputTextListener export default { /** * @returns {Promise} */ getSystemLocale: () => { return ipcRenderer.invoke(IpcChannels.GET_SYSTEM_LOCALE) }, /** * @param {string} path * @param {Record | null | undefined} query * @param {string | null | undefined} searchQueryText */ openInNewWindow: (path, query, searchQueryText) => { ipcRenderer.send(IpcChannels.CREATE_NEW_WINDOW, path, query, searchQueryText) }, /** * @param {string} url */ enableProxy: (url) => { ipcRenderer.send(IpcChannels.ENABLE_PROXY, url) }, disableProxy: () => { ipcRenderer.send(IpcChannels.DISABLE_PROXY) }, /** * @param {string} authorization * @param {string} url */ setInvidiousAuthorization: (authorization, url) => { ipcRenderer.send(IpcChannels.SET_INVIDIOUS_AUTHORIZATION, authorization, url) }, clearInvidiousAuthorization: () => { ipcRenderer.send(IpcChannels.SET_INVIDIOUS_AUTHORIZATION, null) }, startPowerSaveBlocker: () => { ipcRenderer.send(IpcChannels.START_POWER_SAVE_BLOCKER) }, stopPowerSaveBlocker: () => { ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER) }, /** * @returns {Promise} */ getReplaceHttpCache: () => { return ipcRenderer.invoke(IpcChannels.GET_REPLACE_HTTP_CACHE) }, toggleReplaceHttpCache: () => { ipcRenderer.send(IpcChannels.TOGGLE_REPLACE_HTTP_CACHE) }, // Allows programmatic toggling of picture-in-picture mode without accompanying user interaction. // See: https://developer.mozilla.org/en-US/docs/Web/Security/User_activation#transient_activation requestPiP: () => { webFrame.executeJavaScript('document.querySelector("video.player")?.ui.getControls().togglePiP()', true).catch() }, // Allows programmatic toggling of fullscreen without accompanying user interaction. // See: https://developer.mozilla.org/en-US/docs/Web/Security/User_activation#transient_activation requestFullscreen: () => { webFrame.executeJavaScript('document.querySelector("video.player")?.ui.getControls().toggleFullScreen()', true).catch() }, /** * @param {string} key * @returns {Promise} */ playerCacheGet: (key) => { return ipcRenderer.invoke(IpcChannels.PLAYER_CACHE_GET, key) }, /** * @param {string} key * @param {ArrayBuffer} value */ playerCacheSet: async (key, value) => { await ipcRenderer.invoke(IpcChannels.PLAYER_CACHE_SET, key, value) }, /** * @param {string} videoId * @param {string} context * @returns {Promise} */ generatePoToken: (videoId, context) => { return ipcRenderer.invoke(IpcChannels.GENERATE_PO_TOKEN, videoId, context) }, chooseDefaultFolder: () => { ipcRenderer.send(IpcChannels.CHOOSE_DEFAULT_FOLDER) }, /** * @param {string} filename * @param {ArrayBuffer} contents * @returns {Promise} */ writeToDefaultFolder: async (filename, contents) => { return await ipcRenderer.invoke(IpcChannels.WRITE_TO_DEFAULT_FOLDER, filename, contents) }, relaunch: () => { ipcRenderer.send(IpcChannels.RELAUNCH_REQUEST) }, /** * @param {import('../main/externalPlayer').ExternalPlayerPayload} payload */ openInExternalPlayer: (payload) => { // require the user to have interacted with the page recently if (navigator.userActivation.isActive) { ipcRenderer.send(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, payload) } }, /** * @param {( * externalPlayer: string, * unsuportedActions: (import('../constants').UnsupportedPlayerAction)[], * isPlaylist: boolean * ) => void} handler */ handleOpenInExternalPlayerResult: (handler) => { ipcRenderer.on(IpcChannels.OPEN_IN_EXTERNAL_PLAYER_RESULT, (event, externalPlayer, unsupportedActions, isPlaylist) => { handler(externalPlayer, unsupportedActions, isPlaylist) }) }, /** * @param {number} factor */ setZoomFactor: (factor) => { if (typeof factor === 'number' && factor > 0) { webFrame.setZoomFactor(factor) } }, /** * @returns {Promise<{ label: string, value: number, active: boolean }[]>} */ getNavigationHistory: () => { return ipcRenderer.invoke(IpcChannels.GET_NAVIGATION_HISTORY) }, /** * @param {number} action * @param {any} [data] */ dbSettings: (action, data) => { return ipcRenderer.invoke(IpcChannels.DB_SETTINGS, data ? { action, data } : { action }) }, /** * @param {number} action * @param {any} [data] */ dbHistory: (action, data) => { return ipcRenderer.invoke(IpcChannels.DB_HISTORY, data ? { action, data } : { action }) }, /** * @param {number} action * @param {any} [data] */ dbProfiles: (action, data) => { return ipcRenderer.invoke(IpcChannels.DB_PROFILES, data ? { action, data } : { action }) }, /** * @param {number} action * @param {any} [data] */ dbPlaylists: (action, data) => { return ipcRenderer.invoke(IpcChannels.DB_PLAYLISTS, data ? { action, data } : { action }) }, /** * @param {number} action * @param {any} [data] */ dbSearchHistory: (action, data) => { return ipcRenderer.invoke(IpcChannels.DB_SEARCH_HISTORY, data ? { action, data } : { action }) }, /** * @param {number} action * @param {any} [data] */ dbSubscriptionCache: (action, data) => { return ipcRenderer.invoke(IpcChannels.DB_SUBSCRIPTION_CACHE, data ? { action, data } : { action }) }, /** * @param {(route: string) => void} handler */ handleChangeView: (handler) => { ipcRenderer.on(IpcChannels.CHANGE_VIEW, (_, route) => { handler(route) }) }, /** * @param {(url: string) => void} handler */ handleOpenUrl: (handler) => { ipcRenderer.on(IpcChannels.OPEN_URL, (_, url) => { handler(url) }) ipcRenderer.send(IpcChannels.APP_READY) }, /** * Pass `null` to clear the handler * @param {(text: string) => void | null} handler */ handleUpdateSearchInputText: (handler) => { if (currentUpdateSearchInputTextListener) { ipcRenderer.off(IpcChannels.UPDATE_SEARCH_INPUT_TEXT, currentUpdateSearchInputTextListener) currentUpdateSearchInputTextListener = undefined } if (handler) { currentUpdateSearchInputTextListener = (_, text) => { handler(text) } ipcRenderer.on(IpcChannels.UPDATE_SEARCH_INPUT_TEXT, currentUpdateSearchInputTextListener) ipcRenderer.send(IpcChannels.SEARCH_INPUT_HANDLING_READY) } }, /** * @param {(event: number, data: any) => void} handler */ handleSyncSettings: (handler) => { ipcRenderer.on(IpcChannels.SYNC_SETTINGS, (_, { event, data }) => { handler(event, data) }) }, /** * @param {(event: number, data: any) => void} handler */ handleSyncHistory: (handler) => { ipcRenderer.on(IpcChannels.SYNC_HISTORY, (_, { event, data }) => { handler(event, data) }) }, /** * @param {(event: number, data: any) => void} handler */ handleSyncSearchHistory: (handler) => { ipcRenderer.on(IpcChannels.SYNC_SEARCH_HISTORY, (_, { event, data }) => { handler(event, data) }) }, /** * @param {(event: number, data: any) => void} handler */ handleSyncProfiles: (handler) => { ipcRenderer.on(IpcChannels.SYNC_PROFILES, (_, { event, data }) => { handler(event, data) }) }, /** * @param {(event: number, data: any) => void} handler */ handleSyncPlaylists: (handler) => { ipcRenderer.on(IpcChannels.SYNC_PLAYLISTS, (_, { event, data }) => { handler(event, data) }) }, /** * @param {(event: number, data: any) => void} handler */ handleSyncSubscriptionCache: (handler) => { ipcRenderer.on(IpcChannels.SYNC_SUBSCRIPTION_CACHE, (_, { event, data }) => { handler(event, data) }) } } ================================================ FILE: src/preload/main.js ================================================ import { contextBridge } from 'electron/renderer' import api from './interface.js' contextBridge.exposeInMainWorld('ftElectron', api) ================================================ FILE: src/preload/preload-interface.d.ts ================================================ import api from './interface.js' declare global { interface Window { ftElectron: typeof api } } ================================================ FILE: src/renderer/App.css ================================================ @font-face { font-family: Roboto; src: url('assets/font/Roboto-Regular.ttf'); } .app { display: flex; flex-wrap: wrap; font-family: Roboto, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; block-size: 100%; } .routerView { flex: 1 1 0%; margin-block: 18px; margin-inline: 10px; } .banner { inline-size: 85%; margin-block: 40px 0; margin-inline: auto; } .banner + .banner { margin-block: 20px; } .banner-wrapper { margin-block: 0; margin-inline: 10px; } .flexBox { display: block; user-select: unset; } .changeLogTitle { padding-inline: 16px; overflow-wrap: break-word; margin-block-end: 16px; } .changeLogText { overflow-y: scroll; block-size: 40vh; display: block; padding-inline: 16px; margin-block-end: 16px; overflow-wrap: break-word; } .fade-enter-active, .fade-leave-active { transition: opacity 0.15s; } .fade-enter-from, .fade-leave-to { opacity: 0; } @media only screen and (width <= 680px) { .routerView { margin-block: 68px; margin-inline: 8px; } .banner { inline-size: 90%; margin-block: 60px 0; } .flexBox { margin-block: 60px -75px; } .changeLogText { block-size: 65vh; } } ================================================ FILE: src/renderer/App.vue ================================================