├── .babelrc ├── .compilerc ├── .editorconfig ├── .github └── workflows │ ├── build.yml │ └── codeql-analysis.yml ├── .gitignore ├── .gitlab-ci.yml ├── .husky └── pre-commit ├── .prettierrc ├── .sass-lint.yml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── develop.js ├── integration-tests ├── .gitignore ├── chrome-options.go ├── download-chrome-driver.go ├── flow-all.go ├── flow-install.go ├── flow-navigation.go ├── flow-prepare.go ├── go.mod ├── go.sum ├── helpers.go ├── itch-logging.go ├── main.go ├── processgroup_others.go ├── processgroup_windows.go └── versions.go ├── package-lock.json ├── package.json ├── release ├── build.js ├── common.js ├── deploy.js ├── images │ ├── dmgbg.png │ ├── dmgbg.xcf │ ├── installer.gif │ ├── itch-icons │ │ ├── icon1024.png │ │ ├── icon114.png │ │ ├── icon128.png │ │ ├── icon144.png │ │ ├── icon150.png │ │ ├── icon16.png │ │ ├── icon256.png │ │ ├── icon32.png │ │ ├── icon36.png │ │ ├── icon48.png │ │ ├── icon512.png │ │ ├── icon64.png │ │ ├── icon72.png │ │ ├── itch.icns │ │ ├── itch.ico │ │ ├── source.png │ │ └── source.xcf │ ├── kitch-icons │ │ ├── icon1024.png │ │ ├── icon114.png │ │ ├── icon128.png │ │ ├── icon144.png │ │ ├── icon150.png │ │ ├── icon16.png │ │ ├── icon256.png │ │ ├── icon32.png │ │ ├── icon36.png │ │ ├── icon48.png │ │ ├── icon512.png │ │ ├── icon64.png │ │ ├── icon72.png │ │ ├── itch.icns │ │ ├── itch.ico │ │ └── source.png │ └── resize-icons.rb ├── import-i18n-strings.js ├── package-all.js ├── package.js ├── packaging │ ├── build.js │ ├── context.js │ ├── darwin.js │ ├── do-package.js │ ├── test.js │ └── windows.js ├── push-tag.js ├── test-release.js ├── test.js └── tsconfig.json ├── src ├── common │ ├── actions │ │ └── index.ts │ ├── butlerd │ │ ├── errors.ts │ │ ├── messages.ts │ │ ├── net.ts │ │ └── utils.ts │ ├── constants │ │ ├── classification-actions.ts │ │ ├── colors.ts │ │ ├── default-manifest-icons.ts │ │ ├── net.ts │ │ ├── platform-data.ts │ │ ├── search-examples.ts │ │ ├── urls.ts │ │ └── windows.ts │ ├── env.ts │ ├── format │ │ ├── camelify.ts │ │ ├── datetime.ts │ │ ├── errors.ts │ │ ├── exit-code.ts │ │ ├── filesize.ts │ │ ├── operation.ts │ │ ├── platform.ts │ │ ├── price.ts │ │ ├── shape.ts │ │ ├── show-in-explorer.ts │ │ ├── slugify.ts │ │ ├── t.ts │ │ ├── truncate.ts │ │ └── upload.ts │ ├── helpers │ │ ├── bridge.ts │ │ ├── get-by-ids.ts │ │ ├── get-game-status.ts │ │ ├── group-id-by.ts │ │ ├── secret-click.ts │ │ ├── space.ts │ │ ├── spec-to-button.ts │ │ ├── to-tab-data.ts │ │ └── with-timeout.ts │ ├── ipc.ts │ ├── logger │ │ └── index.ts │ ├── modals │ │ ├── index.ts │ │ └── types.ts │ ├── os │ │ ├── platform.ts │ │ └── runtime.ts │ ├── reducers │ │ ├── all.ts │ │ ├── broth.ts │ │ ├── butlerd.ts │ │ ├── commons.ts │ │ ├── derived-reducer.ts │ │ ├── downloads.ts │ │ ├── game-updates.ts │ │ ├── i18n.ts │ │ ├── index.ts │ │ ├── preferences.ts │ │ ├── profile │ │ │ ├── index.ts │ │ │ ├── itchio-uris.ts │ │ │ ├── login.ts │ │ │ └── profile.ts │ │ ├── reducer.ts │ │ ├── setup.ts │ │ ├── status.ts │ │ ├── system-tasks.ts │ │ ├── system.ts │ │ ├── tasks.ts │ │ ├── ui │ │ │ ├── index.ts │ │ │ ├── menu.ts │ │ │ └── search.ts │ │ ├── wind │ │ │ ├── index.ts │ │ │ ├── modals.ts │ │ │ ├── native.ts │ │ │ ├── navigation.ts │ │ │ ├── properties.ts │ │ │ ├── tab-instance │ │ │ │ └── index.ts │ │ │ └── tab-instances.ts │ │ └── winds.ts │ ├── types │ │ ├── errors.ts │ │ ├── index.ts │ │ ├── net.ts │ │ └── sf.ts │ └── util │ │ ├── action-for-game.ts │ │ ├── debounce.ts │ │ ├── frame-name-for-tab.ts │ │ ├── lru-memoize.ts │ │ ├── navigation.ts │ │ ├── net.ts │ │ ├── partition-for-user.ts │ │ ├── route.ts │ │ ├── should-log-action.ts │ │ ├── uuid.ts │ │ └── watcher.ts ├── index.ejs ├── main │ ├── boot │ │ └── test-paths.ts │ ├── broth │ │ ├── formulas.ts │ │ ├── itch-setup.ts │ │ ├── manager.ts │ │ ├── package.ts │ │ ├── platform.ts │ │ ├── self-package.ts │ │ └── unzip.ts │ ├── butlerd │ │ ├── make-butler-instance.ts │ │ └── mcall.ts │ ├── context │ │ └── index.ts │ ├── crash-reporter.ts │ ├── env.ts │ ├── helpers │ │ ├── app.ts │ │ ├── browser-window.ts │ │ └── menu.ts │ ├── index.ts │ ├── inject │ │ ├── inject-captcha.ts │ │ ├── inject-game.ts │ │ └── inject-preload.ts │ ├── logger │ │ ├── console-sink.ts │ │ └── index.ts │ ├── main.ts │ ├── modals.ts │ ├── net │ │ ├── download.ts │ │ ├── errors.ts │ │ ├── index.ts │ │ ├── register-itch-protocol.ts │ │ └── request │ │ │ ├── index.ts │ │ │ └── metal-request.ts │ ├── os │ │ ├── arch.ts │ │ ├── assert-presence.ts │ │ ├── exit.ts │ │ ├── explorer.ts │ │ ├── ifs.ts │ │ ├── sf.ts │ │ └── spawn.ts │ ├── reactors │ │ ├── clipboard.ts │ │ ├── commons.ts │ │ ├── context-menu.ts │ │ ├── context-menu │ │ │ ├── build-template.ts │ │ │ └── flesh-out-template.ts │ │ ├── delay.ts │ │ ├── dialogs │ │ │ ├── change-user.ts │ │ │ ├── clear-browsing-data.ts │ │ │ ├── force-close-game-request.ts │ │ │ ├── index.ts │ │ │ ├── manage-cave.ts │ │ │ ├── manage-game.ts │ │ │ ├── request-cave-uninstall.ts │ │ │ ├── scan-install-locations.ts │ │ │ └── show-game-update.ts │ │ ├── downloads │ │ │ ├── download-ended.ts │ │ │ ├── driver-persistent-state.ts │ │ │ ├── driver.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── operations.ts │ │ │ ├── perform-uninstall.ts │ │ │ └── show-download-error.ts │ │ ├── game-updates.ts │ │ ├── i18n.ts │ │ ├── index.ts │ │ ├── install-locations.ts │ │ ├── launch │ │ │ ├── itch-cave-protocol.ts │ │ │ ├── perform-html-launch.ts │ │ │ ├── perform-launch.ts │ │ │ └── pick-manifest-action.ts │ │ ├── locales.ts │ │ ├── login.ts │ │ ├── make-upload-button.ts │ │ ├── menu.ts │ │ ├── modals-persistent-state.ts │ │ ├── modals.ts │ │ ├── navigation.ts │ │ ├── notifications.ts │ │ ├── open-app-devtools.ts │ │ ├── open-at-login.ts │ │ ├── preboot.ts │ │ ├── preboot │ │ │ └── load-preferences.ts │ │ ├── preferences.ts │ │ ├── profile.ts │ │ ├── proxy.ts │ │ ├── purchases.ts │ │ ├── queue-launch.ts │ │ ├── self-update.ts │ │ ├── setup.ts │ │ ├── silent-location-scan.ts │ │ ├── tab-save.ts │ │ ├── tabs.ts │ │ ├── tasks │ │ │ ├── abort-game.ts │ │ │ ├── abort-task.ts │ │ │ ├── as-task-persistent-state.ts │ │ │ ├── as-task.ts │ │ │ ├── explore-cave.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── queue-cave-reinstall.ts │ │ │ ├── queue-cave-uninstall.ts │ │ │ ├── queue-game.ts │ │ │ ├── show-install-error-modal.ts │ │ │ ├── switch-version-cave.ts │ │ │ └── view-cave-details.ts │ │ ├── tray-persistent-state.ts │ │ ├── tray.ts │ │ ├── triggers.ts │ │ ├── updater.ts │ │ ├── url.ts │ │ ├── web-contents-context-menu.ts │ │ ├── web-contents.ts │ │ ├── web-contents │ │ │ ├── parse-well-known-url.ts │ │ │ └── web-contents-state.ts │ │ └── winds.ts │ ├── store.ts │ └── util │ │ ├── config.ts │ │ ├── paths.ts │ │ ├── resources.ts │ │ ├── rng.ts │ │ ├── url.ts │ │ └── useragent.ts ├── renderer │ ├── App │ │ ├── AppContents.tsx │ │ ├── Layout │ │ │ ├── NonLocalIndicator.tsx │ │ │ ├── StatusBar.tsx │ │ │ └── index.tsx │ │ ├── Modals.tsx │ │ └── index.tsx │ ├── basics │ │ ├── Button.tsx │ │ ├── Cover │ │ │ ├── GifMarker.tsx │ │ │ ├── SmartImage.tsx │ │ │ └── index.tsx │ │ ├── DownloadProgressSpan.tsx │ │ ├── EmptyState.tsx │ │ ├── ErrorState.tsx │ │ ├── Filler.tsx │ │ ├── FiltersContainer.tsx │ │ ├── Floater.tsx │ │ ├── FormattedDuration.tsx │ │ ├── GameStats.tsx │ │ ├── GameStatusGetter.tsx │ │ ├── Icon.tsx │ │ ├── IconButton.tsx │ │ ├── LastPlayed.tsx │ │ ├── Link.tsx │ │ ├── LoadingCircle │ │ │ ├── Circle.tsx │ │ │ └── index.tsx │ │ ├── MainAction.tsx │ │ ├── Markdown.tsx │ │ ├── NavigationBar.tsx │ │ ├── PlatformIcons │ │ │ ├── PlatformIcon.tsx │ │ │ └── index.tsx │ │ ├── RandomSvg.tsx │ │ ├── RowButton.tsx │ │ ├── SelectRow.tsx │ │ ├── SimpleSelect │ │ │ ├── DefaultOptionComponent.tsx │ │ │ └── index.tsx │ │ ├── TimeAgo.tsx │ │ ├── TitleBar │ │ │ ├── NewVersionAvailable.tsx │ │ │ ├── UserMenu.tsx │ │ │ └── index.tsx │ │ ├── TotalPlaytime.tsx │ │ ├── UploadIcon.tsx │ │ └── modal-styles.tsx │ ├── bridge.ts │ ├── butlerd │ │ ├── invalidators.ts │ │ └── rcall.ts │ ├── env.ts │ ├── fonts │ │ ├── icomoon │ │ │ ├── Read Me.txt │ │ │ ├── demo-files │ │ │ │ ├── demo.css │ │ │ │ └── demo.js │ │ │ ├── demo.html │ │ │ ├── fonts │ │ │ │ ├── icomoon.eot │ │ │ │ ├── icomoon.svg │ │ │ │ ├── icomoon.ttf │ │ │ │ └── icomoon.woff │ │ │ ├── selection.json │ │ │ └── style.css │ │ └── lato │ │ │ ├── assets │ │ │ ├── lato-black.woff2 │ │ │ ├── lato-blackitalic.woff2 │ │ │ ├── lato-bold.woff2 │ │ │ ├── lato-bolditalic.woff2 │ │ │ ├── lato-hairline.woff2 │ │ │ ├── lato-hairlineitalic.woff2 │ │ │ ├── lato-heavy.woff2 │ │ │ ├── lato-heavyitalic.woff2 │ │ │ ├── lato-italic.woff2 │ │ │ ├── lato-light.woff2 │ │ │ ├── lato-lightitalic.woff2 │ │ │ ├── lato-medium.woff2 │ │ │ ├── lato-mediumitalic.woff2 │ │ │ ├── lato-regular.woff2 │ │ │ ├── lato-semibold.woff2 │ │ │ ├── lato-semibolditalic.woff2 │ │ │ ├── lato-thin.woff2 │ │ │ └── lato-thinitalic.woff2 │ │ │ ├── lato.html │ │ │ ├── latofonts-custom.css │ │ │ ├── latofonts.css │ │ │ └── latostyle.css │ ├── global-styles │ │ ├── base.ts │ │ ├── hint.ts │ │ ├── index.ts │ │ ├── reset.ts │ │ └── scroll.ts │ ├── helpers │ │ ├── doAsync.tsx │ │ ├── getDisplayName.ts │ │ └── whenClickNavigates.ts │ ├── hocs │ │ ├── butlerCaller │ │ │ └── index.tsx │ │ ├── hook.tsx │ │ ├── tab-utils.tsx │ │ ├── watching.tsx │ │ ├── withHover.tsx │ │ ├── withProfile.tsx │ │ └── withTab.tsx │ ├── index.tsx │ ├── logger │ │ └── index.ts │ ├── modal-widgets │ │ ├── ClearBrowsingData.tsx │ │ ├── ConfirmQuit.tsx │ │ ├── ExploreJson.tsx │ │ ├── ManageCave.tsx │ │ ├── ManageGame.tsx │ │ ├── PlanInstall │ │ │ ├── InstallLocationOptionComponent.tsx │ │ │ ├── UploadOptionComponent.tsx │ │ │ ├── index.tsx │ │ │ └── select-common.tsx │ │ ├── PrereqsState.tsx │ │ ├── RecaptchaInput.tsx │ │ ├── SecretSettings.tsx │ │ ├── SendFeedback.tsx │ │ ├── ShowError.tsx │ │ ├── SwitchVersionCave │ │ │ ├── CustomDate.tsx │ │ │ └── index.tsx │ │ ├── TwoFactorInput.tsx │ │ ├── index.tsx │ │ └── styles.tsx │ ├── modals.ts │ ├── pages │ │ ├── AppLogPage │ │ │ ├── Log.tsx │ │ │ └── index.tsx │ │ ├── BrowserPage │ │ │ ├── BrowserBar.tsx │ │ │ ├── BrowserContext │ │ │ │ ├── BrowserContextConstants.tsx │ │ │ │ ├── BrowserContextGame.tsx │ │ │ │ └── index.tsx │ │ │ ├── DisabledBrowser.tsx │ │ │ ├── index.tsx │ │ │ └── newTabItems.ts │ │ ├── CavePage.tsx │ │ ├── CollectionPage │ │ │ └── index.tsx │ │ ├── CollectionsPage │ │ │ ├── CollectionPreview.tsx │ │ │ └── index.tsx │ │ ├── CrashyPage.tsx │ │ ├── DashboardPage │ │ │ ├── DraftStatus.tsx │ │ │ ├── ProfileGameStats.tsx │ │ │ └── index.tsx │ │ ├── DownloadsPage │ │ │ ├── Chart.tsx │ │ │ ├── GameUpdateRow.tsx │ │ │ ├── Row.tsx │ │ │ └── index.tsx │ │ ├── FeaturedPage.tsx │ │ ├── GamePage.tsx │ │ ├── InstallPage.tsx │ │ ├── LibraryPage │ │ │ ├── InstalledPage │ │ │ │ └── index.tsx │ │ │ ├── OwnedPage │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── LocationPage │ │ │ ├── LocationContents.tsx │ │ │ ├── LocationItemExtras.tsx │ │ │ └── index.tsx │ │ ├── LocationsPage │ │ │ ├── LocationSizeBar.tsx │ │ │ ├── LocationSummary.tsx │ │ │ └── index.tsx │ │ ├── NewTabPage │ │ │ └── index.tsx │ │ ├── PageStyles │ │ │ ├── boxes.tsx │ │ │ ├── games.tsx │ │ │ └── stats.tsx │ │ ├── PreferencesPage │ │ │ ├── AdvancedSettings.tsx │ │ │ ├── BehaviorSettings.tsx │ │ │ ├── BrothComponent.tsx │ │ │ ├── BrothComponents.tsx │ │ │ ├── Checkbox.tsx │ │ │ ├── InstallLocationSettings.tsx │ │ │ ├── Label.tsx │ │ │ ├── LanguageSettings.tsx │ │ │ ├── OpenAtLoginErrorMessage.tsx │ │ │ ├── ProxySettings.tsx │ │ │ └── index.tsx │ │ ├── ScanInstallLocationsPage │ │ │ └── index.tsx │ │ └── common │ │ │ ├── CommonFilters.tsx │ │ │ ├── Filter.tsx │ │ │ ├── FilterInput.tsx │ │ │ ├── GameStripe.tsx │ │ │ ├── ItemList.tsx │ │ │ ├── Page.tsx │ │ │ ├── ScanningIndicator.tsx │ │ │ ├── SearchControl.tsx │ │ │ ├── Sort.tsx │ │ │ ├── SortsAndFilters.tsx │ │ │ ├── StandardGameCover.tsx │ │ │ ├── StandardGameDesc.tsx │ │ │ ├── StandardMainAction.tsx │ │ │ └── StandardSaleRibbon.tsx │ ├── scenes │ │ ├── GateScene │ │ │ ├── BlockingOperation.tsx │ │ │ ├── LoginForm │ │ │ │ └── index.tsx │ │ │ ├── LoginScreen.tsx │ │ │ ├── LogoIndicator.tsx │ │ │ ├── RememberedProfiles │ │ │ │ ├── RememberedProfile.tsx │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ └── HubScene │ │ │ ├── HubContent.tsx │ │ │ ├── Meats │ │ │ ├── Meat.tsx │ │ │ ├── index.tsx │ │ │ └── types.tsx │ │ │ ├── Sidebar │ │ │ ├── Item.tsx │ │ │ ├── Logo.tsx │ │ │ ├── PrimeDownload │ │ │ │ ├── GameTitle.tsx │ │ │ │ ├── PrimeDownloadContents.tsx │ │ │ │ └── index.tsx │ │ │ ├── Search.tsx │ │ │ ├── SearchResultsBar │ │ │ │ ├── GameSearchResult.tsx │ │ │ │ └── index.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── Tab.tsx │ │ │ └── styles.ts │ │ │ └── index.tsx │ ├── series │ │ ├── CollectionSeries.tsx │ │ ├── GameSeries.tsx │ │ └── Series.tsx │ ├── store.ts │ ├── styles.ts │ ├── t.tsx │ └── util │ │ ├── get-user-cover-url.ts │ │ ├── rng.ts │ │ └── url.ts ├── static │ ├── .gitignore │ ├── images │ │ ├── avatars │ │ │ ├── frog-blue.svg │ │ │ ├── frog-cyan.svg │ │ │ ├── frog-gold.svg │ │ │ ├── frog-red.svg │ │ │ └── frog.svg │ │ ├── logos │ │ │ ├── app-white-contour.svg │ │ │ ├── app-white.svg │ │ │ ├── itchio-black.svg │ │ │ ├── itchio-textless-black.svg │ │ │ ├── itchio-textless-pink.svg │ │ │ ├── itchio-textless-white.svg │ │ │ ├── itchio-white.svg │ │ │ ├── itchio.svg │ │ │ ├── tray-monochrome.svg │ │ │ └── tray-template.svg │ │ ├── tray │ │ │ ├── itch.png │ │ │ └── kitch.png │ │ └── window │ │ │ ├── itch │ │ │ ├── icon-32.png │ │ │ └── icon.png │ │ │ └── kitch │ │ │ ├── icon-32.png │ │ │ └── icon.png │ ├── locales.json │ └── locales │ │ ├── ar.json │ │ ├── be.json │ │ ├── bg.json │ │ ├── bn.json │ │ ├── bn_BD.json │ │ ├── br.json │ │ ├── ca.json │ │ ├── chr.json │ │ ├── cs.json │ │ ├── cy.json │ │ ├── da.json │ │ ├── de.json │ │ ├── el.json │ │ ├── en.json │ │ ├── eo.json │ │ ├── es.json │ │ ├── es_PE.json │ │ ├── et.json │ │ ├── fa.json │ │ ├── fi.json │ │ ├── fil.json │ │ ├── fr.json │ │ ├── ga.json │ │ ├── he.json │ │ ├── hi.json │ │ ├── hr.json │ │ ├── hu.json │ │ ├── in.json │ │ ├── is.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── lb.json │ │ ├── list.json │ │ ├── lv.json │ │ ├── mn.json │ │ ├── ms.json │ │ ├── my.json │ │ ├── nb.json │ │ ├── nb_NO.json │ │ ├── nl.json │ │ ├── or.json │ │ ├── pl.json │ │ ├── pt_BR.json │ │ ├── pt_PT.json │ │ ├── ro.json │ │ ├── ru.json │ │ ├── sk.json │ │ ├── sr.json │ │ ├── sv.json │ │ ├── ta.json │ │ ├── th.json │ │ ├── tr.json │ │ ├── uk.json │ │ ├── vi.json │ │ ├── zh.json │ │ ├── zh_CN.json │ │ ├── zh_Hans.json │ │ ├── zh_Hant.json │ │ └── zh_TW.json └── vendor │ └── memory_streams │ ├── LICENSE │ ├── index.d.ts │ ├── index.js │ └── lib │ ├── ReadableStream.js │ └── WritableStream.js ├── tsconfig.json ├── typings ├── array-move.d.ts ├── augmentations.d.ts ├── btoa.d.ts ├── colors-safe.d.ts ├── console.d.ts ├── fast-memoize.d.ts ├── ftl-redux-electron-store.d.ts ├── logrotate-stream.d.ts ├── marked-extra.d.ts ├── polished.d.ts ├── progress-stream.d.ts ├── react-container-dimensions.d.ts ├── react-hint.d.ts ├── react-modal.d.ts ├── react-onclickoutside.d.ts ├── react-sortable-hoc.d.ts ├── serve-static.d.ts ├── shell-quote.d.ts ├── tar.d.ts ├── tinycolor2.d.ts └── underscore.d.ts ├── vendor └── signtool.exe └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [["transform-node-env-inline"]], 3 | "env": { 4 | "development": { 5 | "plugins": [ 6 | [ 7 | "transform-async-to-module-method", 8 | { 9 | "module": "bluebird", 10 | "method": "coroutine" 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.compilerc: -------------------------------------------------------------------------------- 1 | { 2 | "application/javascript": { 3 | "passthrough": true 4 | }, 5 | "text/typescript": { 6 | "target": "es2017", 7 | "lib": ["dom", "es6"], 8 | "module": "commonjs", 9 | "noUnusedLocals": true, 10 | "inlineSourceMap": true, 11 | "inlineSources": true, 12 | "jsx": "react", 13 | "allowJs": true, 14 | "skipLibCheck": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "build" 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | SKIP_CODESIGN: 1 7 | 8 | jobs: 9 | build-linux: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@master 14 | 15 | - name: test 16 | run: | 17 | npm ci 18 | 19 | - name: release 20 | run: | 21 | node release/package-all.js --os linux --arch amd64 22 | 23 | build-windows: 24 | runs-on: windows-latest 25 | 26 | steps: 27 | - uses: actions/checkout@master 28 | 29 | - name: test 30 | run: | 31 | npm ci 32 | 33 | - name: release 34 | run: | 35 | node release/package-all.js --os windows --arch amd64 36 | 37 | build-macos: 38 | runs-on: macos-latest 39 | 40 | steps: 41 | - uses: actions/checkout@master 42 | - uses: actions/setup-go@v5 43 | with: 44 | go-version: '^1.22.1' 45 | 46 | - name: test 47 | run: | 48 | npm ci 49 | 50 | - name: release 51 | run: | 52 | node release/package-all.js --os darwin --arch amd64 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package manager stuff 2 | /node_modules 3 | /docs/node_modules 4 | config.json 5 | npm-debug.log 6 | /.cache 7 | 8 | # build artifacts 9 | /*.dmg 10 | /*.zip 11 | /*.app 12 | /app 13 | /test 14 | /tests 15 | /tmp 16 | /screenshots 17 | /.tscache 18 | /.awcache 19 | *.map 20 | *.tmp.txt 21 | /butler 22 | *.exe 23 | /artifacts 24 | 25 | # capsule stuff 26 | /*.mp4 27 | 28 | # coverage 29 | /coverage 30 | *.lcov 31 | .nyc_output 32 | 33 | # build+packaging temp files (CI) 34 | /build 35 | /cache 36 | /stage* 37 | /app* 38 | /dist* 39 | /prefix* 40 | /deb-stage 41 | /rpm-stage 42 | /aur-stage 43 | /portable-stage 44 | /linux-extras 45 | /packages 46 | /*.app 47 | /*.dmg 48 | 49 | # integration tests temp 50 | .chromedriver 51 | *.log.txt 52 | 53 | # OS-specific junk 54 | .DS_Store 55 | 56 | # golang-specific stuff 57 | /gopath 58 | 59 | # webpack stuff 60 | stats.json 61 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | 2 | stages: 3 | - package 4 | - deploy 5 | 6 | package-linux-amd64: 7 | stage: package 8 | tags: 9 | - linux 10 | script: 11 | - npm ci 12 | - node release/package-all.js --os linux --arch amd64 13 | artifacts: 14 | expire_in: 1 week 15 | paths: 16 | - artifacts 17 | 18 | package-darwin-amd64: 19 | stage: package 20 | tags: 21 | - darwin 22 | script: 23 | - npm ci 24 | - node release/package-all.js --os darwin --arch amd64 25 | artifacts: 26 | expire_in: 1 week 27 | paths: 28 | - artifacts 29 | 30 | package-windows-386: 31 | stage: package 32 | tags: 33 | - windows 34 | script: 35 | - npm ci 36 | - node release/package-all.js --os windows --arch 386 37 | artifacts: 38 | expire_in: 1 week 39 | paths: 40 | - artifacts 41 | 42 | package-windows-amd64: 43 | stage: package 44 | tags: 45 | - windows 46 | script: 47 | - npm ci 48 | - node release/package-all.js --os windows --arch amd64 49 | artifacts: 50 | expire_in: 1 week 51 | paths: 52 | - artifacts 53 | 54 | deploy-itchio: 55 | stage: deploy 56 | when: manual 57 | tags: 58 | - linux 59 | only: 60 | - tags 61 | script: 62 | - npm ci 63 | - node release/deploy.js 64 | dependencies: 65 | - package-linux-amd64 66 | - package-darwin-amd64 67 | - package-windows-386 68 | - package-windows-amd64 69 | 70 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } 4 | -------------------------------------------------------------------------------- /.sass-lint.yml: -------------------------------------------------------------------------------- 1 | options: 2 | formatter: stylish 3 | files: 4 | include: '**/*.s+(a|c)ss' 5 | rules: 6 | # Extends 7 | extends-before-mixins: 1 8 | extends-before-declarations: 1 9 | placeholder-in-extend: 1 10 | 11 | # Mixins 12 | mixins-before-declarations: 1 13 | 14 | # Line Spacing 15 | one-declaration-per-line: 1 16 | empty-line-between-blocks: 1 17 | single-line-per-selector: 1 18 | 19 | # Disallows 20 | no-debug: 1 21 | no-duplicate-properties: 0 22 | no-empty-rulesets: 1 23 | no-extends: 0 24 | no-ids: 1 25 | no-important: 1 26 | no-warn: 1 27 | no-color-keywords: 0 28 | no-invalid-hex: 1 29 | no-css-comments: 1 30 | no-color-literals: 0 31 | 32 | # Style Guide 33 | border-zero: 1 34 | clean-import-paths: 1 35 | empty-args: 1 36 | hex-length: 1 37 | hex-notation: 1 38 | indentation: 1 39 | leading-zero: 1 40 | nesting-depth: 0 41 | property-sort-order: 0 42 | quotes: 1 43 | variable-for-property: 1 44 | zero-unit: 1 45 | 46 | # Inner Spacing 47 | space-after-comma: 1 48 | space-before-colon: 1 49 | space-after-colon: 1 50 | space-before-brace: 1 51 | space-before-bang: 1 52 | space-after-bang: 1 53 | space-between-parens: 1 54 | 55 | # Final Items 56 | trailing-semicolon: 1 57 | final-newline: 1 58 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Main Process", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 9223, 9 | }, 10 | ] 11 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "**/.git": true, 5 | "**/.svn": true, 6 | "**/.hg": true, 7 | "**/.DS_Store": true, 8 | ".nyc_output": true, 9 | "node_modules": true, 10 | "tmp": true, 11 | "tests": true, 12 | "app": true, 13 | "stage": true, 14 | "coverage": true, 15 | "build": true, 16 | "dist": true, 17 | ".tscache": true, 18 | ".cache": true, 19 | "gopath": true 20 | }, 21 | "files.eol": "\n", 22 | "editor.tabSize": 2, 23 | "typescript.tsdk": "./node_modules/typescript/lib", 24 | "prettier.trailingComma": "es5", 25 | "[typescript]": { 26 | "editor.formatOnSave": true 27 | }, 28 | "[typescriptreact]": { 29 | "editor.formatOnSave": true 30 | }, 31 | "typescript.preferences.importModuleSpecifier": "non-relative" 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2018 itch corp., https://itch.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /integration-tests/.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | runner 3 | -------------------------------------------------------------------------------- /integration-tests/flow-all.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func allFlows(r *runner) { 4 | prepareFlow(r) 5 | navigationFlow(r) 6 | installFlow(r) 7 | } 8 | -------------------------------------------------------------------------------- /integration-tests/flow-navigation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const currTab = ".meat-tab.visible " 4 | 5 | func navigationFlow(r *runner) { 6 | must(r.waitForVisible(".user-menu")) 7 | 8 | r.logf("navigating to dashboard") 9 | must(r.click("#sidebar a[href='itch://dashboard']")) 10 | 11 | const firstTitleSelector = currTab + ".gamedesc--title" 12 | 13 | r.logf("sorting by title, A-Z") 14 | must(r.click(currTab + ".sortby--title--default")) 15 | r.logf("ensuring the A-Z sorting is correct") 16 | must(r.waitUntilTextExists(firstTitleSelector, "111 first")) 17 | 18 | r.logf("sorting by title, Z-A") 19 | must(r.click(currTab + ".sortby--title--reverse")) 20 | r.logf("ensuring the Z-A sorting is correct") 21 | must(r.waitUntilTextExists(firstTitleSelector, "zzz last")) 22 | } 23 | -------------------------------------------------------------------------------- /integration-tests/flow-prepare.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func prepareFlow(r *runner) { 4 | r.logf("logging in with valid credentials") 5 | // using workaround so we don't get hit by recaptcha 6 | must(r.setValue("#login-username", "#api-key")) 7 | must(r.setValue("#login-password", testAccountAPIKey)) 8 | must(r.click("#login-button")) 9 | must(r.waitForVisible(".user-menu")) 10 | } 11 | -------------------------------------------------------------------------------- /integration-tests/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/itchio/itch/integration-tests 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/hpcloud/tail v1.0.0 7 | github.com/itchio/go-selenium v0.2.1-0.20200828213652-84a355070707 8 | github.com/itchio/ox v0.0.0-20200728133515-5b7a81fb16fe 9 | github.com/logrusorgru/aurora v0.0.0-20190428105938-cea283e61946 10 | github.com/onsi/ginkgo v1.14.0 // indirect 11 | github.com/onsi/gocleanup v0.0.0-20140331211545-c1a5478700b5 12 | github.com/pkg/errors v0.9.1 13 | ) 14 | -------------------------------------------------------------------------------- /integration-tests/itch-logging.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/logrusorgru/aurora" 9 | ) 10 | 11 | type ItchLogLine struct { 12 | Time int64 `json:"time"` 13 | Level int `json:"level"` 14 | Msg string `json:"msg"` 15 | Name string `json:"name"` 16 | } 17 | 18 | func (line *ItchLogLine) String() string { 19 | timestamp := time.Unix(line.Time/1000, 0) 20 | 21 | res := aurora.Sprintf(aurora.Gray(5, "%s "), timestamp.Format("15:04:05.000")) 22 | 23 | switch line.Level { 24 | case 60: 25 | res += aurora.Sprintf(aurora.BgRed("%s"), "FATAL") 26 | case 50: 27 | res += aurora.Sprintf(aurora.Red("%s"), "ERROR") 28 | case 40: 29 | res += aurora.Sprintf(aurora.Yellow("%s"), "WARN") 30 | case 30: 31 | res += aurora.Sprintf(aurora.Green("%s"), "INFO") 32 | case 20: 33 | res += aurora.Sprintf(aurora.Blue("%s"), "DEBUG") 34 | default: 35 | res += aurora.Sprintf(aurora.Gray(5, "%s"), "TRACE") 36 | } 37 | if line.Name != "" { 38 | res += fmt.Sprintf(" (%s)", line.Name) 39 | } 40 | 41 | res += fmt.Sprintf(" %s", line.Msg) 42 | return res 43 | } 44 | 45 | func parseLogLine(text string) (*ItchLogLine, error) { 46 | var line ItchLogLine 47 | 48 | err := json.Unmarshal([]byte(text), &line) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return &line, err 54 | } 55 | -------------------------------------------------------------------------------- /integration-tests/processgroup_others.go: -------------------------------------------------------------------------------- 1 | //+build !windows 2 | 3 | package main 4 | 5 | func SetupProcessGroup() error { 6 | // nothing to do on non-windows platforms 7 | return nil 8 | } 9 | -------------------------------------------------------------------------------- /integration-tests/processgroup_windows.go: -------------------------------------------------------------------------------- 1 | //+build windows 2 | 3 | package main 4 | 5 | import ( 6 | "syscall" 7 | "unsafe" 8 | 9 | "github.com/itchio/ox/syscallex" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | func SetupProcessGroup() error { 14 | // see https://github.com/itchio/itch/issues/1784 15 | jobObject, err := syscallex.CreateJobObject(nil, nil) 16 | if err != nil { 17 | return errors.WithMessage(err, "While creating job object") 18 | } 19 | 20 | jobObjectInfo := new(syscallex.JobObjectExtendedLimitInformation) 21 | jobObjectInfo.BasicLimitInformation.LimitFlags = syscallex.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 22 | jobObjectInfoPtr := uintptr(unsafe.Pointer(jobObjectInfo)) 23 | jobObjectInfoSize := unsafe.Sizeof(*jobObjectInfo) 24 | 25 | err = syscallex.SetInformationJobObject( 26 | jobObject, 27 | syscallex.JobObjectInfoClass_JobObjectExtendedLimitInformation, 28 | jobObjectInfoPtr, 29 | jobObjectInfoSize, 30 | ) 31 | if err != nil { 32 | return errors.WithMessage(err, "Setting KILL_ON_JOB_CLOSE") 33 | } 34 | 35 | processHandle, err := syscall.GetCurrentProcess() 36 | if err != nil { 37 | return errors.WithMessage(err, "Getting current process handle") 38 | } 39 | 40 | err = syscallex.AssignProcessToJobObject(jobObject, processHandle) 41 | if err != nil { 42 | return errors.WithMessage(err, "While associating process with job object") 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /integration-tests/versions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const electronVersion = "22.3.27" 4 | const chromeDriverVersionString = "ChromeDriver 108.0.5359.215" 5 | -------------------------------------------------------------------------------- /release/build.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const { build } = require("./packaging/build"); 5 | const { parseContext } = require("./packaging/context"); 6 | 7 | async function main() { 8 | const cx = await parseContext(); 9 | await build(cx); 10 | } 11 | 12 | main(); 13 | -------------------------------------------------------------------------------- /release/deploy.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const fs = require("fs"); 5 | const { $ } = require("@itchio/bob"); 6 | const { getAppName, getBuildVersion } = require("./common"); 7 | const ospath = require("path"); 8 | 9 | async function ciDeploy() { 10 | const dirs = fs.readdirSync("./artifacts"); 11 | 12 | /** @type {{path: string, os: string, arch: string}[]} */ 13 | const packages = []; 14 | for (const dir of dirs) { 15 | const [os, arch] = dir.split("-"); 16 | packages.push({ path: dir, os, arch }); 17 | console.log(`Queuing ${os}-${arch} build`); 18 | } 19 | 20 | console.log("Grabbing butler"); 21 | const butlerUrl = `https://broth.itch.zone/butler/linux-amd64/LATEST/.zip`; 22 | $(`curl -L ${butlerUrl} -o butler.zip`); 23 | $(`unzip butler.zip`); 24 | $(`./butler --version`); 25 | 26 | let wd = process.cwd(); 27 | for (const pkg of packages) { 28 | const { os, arch, path } = pkg; 29 | let artifactPath = ospath.join(wd, "artifacts", path); 30 | if (os === "darwin") { 31 | artifactPath = `${artifactPath}/${getAppName()}.app`; 32 | } 33 | 34 | let butlerChannel = `${os}-${arch}`; 35 | const butlerTarget = `itchio/${getAppName()}`; 36 | console.log(`Pushing ${os}-${arch} to itch.io...`); 37 | let butlerCmd = `./butler push ${artifactPath} ${butlerTarget}:${butlerChannel} --userversion=${getBuildVersion()} --no-auto-wrap`; 38 | $(butlerCmd); 39 | } 40 | } 41 | 42 | ciDeploy(); 43 | -------------------------------------------------------------------------------- /release/images/dmgbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/dmgbg.png -------------------------------------------------------------------------------- /release/images/dmgbg.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/dmgbg.xcf -------------------------------------------------------------------------------- /release/images/installer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/installer.gif -------------------------------------------------------------------------------- /release/images/itch-icons/icon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon1024.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon114.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon128.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon144.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon150.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon16.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon256.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon32.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon36.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon48.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon512.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon64.png -------------------------------------------------------------------------------- /release/images/itch-icons/icon72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/icon72.png -------------------------------------------------------------------------------- /release/images/itch-icons/itch.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/itch.icns -------------------------------------------------------------------------------- /release/images/itch-icons/itch.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/itch.ico -------------------------------------------------------------------------------- /release/images/itch-icons/source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/source.png -------------------------------------------------------------------------------- /release/images/itch-icons/source.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/itch-icons/source.xcf -------------------------------------------------------------------------------- /release/images/kitch-icons/icon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon1024.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon114.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon128.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon144.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon150.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon16.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon256.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon32.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon36.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon48.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon512.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon64.png -------------------------------------------------------------------------------- /release/images/kitch-icons/icon72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/icon72.png -------------------------------------------------------------------------------- /release/images/kitch-icons/itch.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/itch.icns -------------------------------------------------------------------------------- /release/images/kitch-icons/itch.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/itch.ico -------------------------------------------------------------------------------- /release/images/kitch-icons/source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/release/images/kitch-icons/source.png -------------------------------------------------------------------------------- /release/images/resize-icons.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | system("convert itch-icons/source.png -modulate 100,100,15 kitch-icons/source.png") 4 | 5 | Dir["*-icons/"].each do |dir| 6 | %w(16 32 36 48 64 72 114 128 144 150 256 512 1024).each do |size| 7 | puts "#{dir}#{size}" 8 | system("convert #{dir}source.png -filter Lanczos -resize #{size}x#{size} #{dir}icon#{size}.png") 9 | end 10 | end 11 | 12 | %w(itch kitch).each do |app| 13 | system("cp -f #{app}-icons/icon256.png ../../src/static/images/tray/#{app}.png") 14 | system("cp -f #{app}-icons/icon16.png ../../src/static/images/tray/#{app}-small.png") 15 | end 16 | 17 | puts "done - don't forget to optimize + .icns / .ico!" 18 | -------------------------------------------------------------------------------- /release/import-i18n-strings.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const { lstatSync } = require("fs"); 5 | const { $ } = require("@itchio/bob"); 6 | 7 | async function importStrings() { 8 | let src = `../itch-i18n/locales`; 9 | try { 10 | lstatSync(src); 11 | } catch (e) { 12 | console.log(`Missing ../itch-i18n, not importing anything`); 13 | process.exit(1); 14 | } 15 | let dst = `./src/static/locales`; 16 | 17 | $(`rm -rf ${dst}`); 18 | $(`cp -rfv ${src} ${dst}`); 19 | } 20 | 21 | importStrings(); 22 | -------------------------------------------------------------------------------- /release/package-all.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const { parseContext } = require("./packaging/context"); 5 | const { build } = require("./packaging/build"); 6 | const { doPackage } = require("./packaging/do-package"); 7 | const { test } = require("./packaging/test"); 8 | 9 | async function main() { 10 | const cx = await parseContext(); 11 | 12 | await build(cx); 13 | await doPackage(cx); 14 | await test(cx); 15 | } 16 | 17 | main().catch((e) => { 18 | throw e; 19 | }); 20 | -------------------------------------------------------------------------------- /release/package.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const { doPackage } = require("./packaging/do-package"); 5 | const { parseContext } = require("./packaging/context"); 6 | 7 | async function main() { 8 | const cx = await parseContext(); 9 | await doPackage(cx); 10 | } 11 | 12 | main(); 13 | -------------------------------------------------------------------------------- /release/packaging/test.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const { $, cd } = require("@itchio/bob"); 5 | const ospath = require("path"); 6 | 7 | /** 8 | * @param {import("./context").Context} cx 9 | */ 10 | async function test(cx) { 11 | await cd("integration-tests", async () => { 12 | $(`go build -o runner -v`); 13 | }); 14 | process.env.ELECTRON_DISABLE_SANDBOX = "1"; 15 | 16 | if (cx.testDev) { 17 | console.log("Will test development version"); 18 | delete process.env.ITCH_INTEGRATION_BINARY_PATH; 19 | } else { 20 | const binaryPath = ospath.join( 21 | cx.artifactDir, 22 | cx.binarySubdir, 23 | cx.binaryName 24 | ); 25 | console.log(`Will test production binary at (${binaryPath})`); 26 | process.env.ITCH_INTEGRATION_BINARY_PATH = binaryPath; 27 | } 28 | 29 | if (process.platform === "linux" && process.env.CI) { 30 | console.log("Running through xvfb"); 31 | $(`xvfb-run -a -s "-screen 0 1280x720x24" ./integration-tests/runner`); 32 | } else { 33 | console.log("Running normally - requires a running desktop environment"); 34 | $(`./integration-tests/runner`); 35 | } 36 | } 37 | 38 | module.exports = { test }; 39 | 40 | -------------------------------------------------------------------------------- /release/packaging/windows.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const { $ } = require("@itchio/bob"); 5 | const fs = require("fs"); 6 | const ospath = require("path"); 7 | const { toUnixPath } = require("./context"); 8 | 9 | /** 10 | * @param {import("./context").Context} cx 11 | * @param {string} packageDir 12 | */ 13 | async function sign(cx, packageDir) { 14 | if (!fs.existsSync(packageDir)) { 15 | throw new Error(`windows.sign: packageDir should exist: (${packageDir})`); 16 | } 17 | 18 | const exePath = toUnixPath( 19 | ospath.join(packageDir, cx.binarySubdir, cx.binaryName) 20 | ); 21 | console.log(`Exe path (${exePath})`); 22 | if (!fs.existsSync(exePath)) { 23 | throw new Error(`windows.sign: exePath should exist: (${exePath})`); 24 | } 25 | 26 | // forward-slashes are doubled because of mingw, see http://www.mingw.org/wiki/Posix_path_conversion 27 | let signParams = 28 | '//v //s MY //n "itch corp." //fd sha256 //tr http://timestamp.comodoca.com/?td=sha256 //td sha256 //a'; 29 | let signtoolPath = "vendor/signtool.exe"; 30 | 31 | $(`${signtoolPath} sign ${signParams} "${exePath}"`); 32 | } 33 | 34 | module.exports = { sign }; 35 | 36 | -------------------------------------------------------------------------------- /release/test-release.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const { $ } = require("@itchio/bob"); 5 | 6 | async function main() { 7 | console.log("Wiping build/"); 8 | $("rm -rf build/"); 9 | 10 | $(`node release/build.js --detect-osarch`); 11 | $(`node release/package.js --detect-osarch`); 12 | } 13 | 14 | main().catch((e) => { 15 | console.error("In main: ", e.stack); 16 | }); 17 | -------------------------------------------------------------------------------- /release/test.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const { test } = require("./packaging/test"); 5 | const { parseContext } = require("./packaging/context"); 6 | 7 | async function main() { 8 | const cx = await parseContext(); 9 | await test(cx); 10 | } 11 | 12 | main(); 13 | -------------------------------------------------------------------------------- /release/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "skipLibCheck": true, 6 | "allowJs": true, 7 | } 8 | } -------------------------------------------------------------------------------- /src/common/constants/classification-actions.ts: -------------------------------------------------------------------------------- 1 | import { ClassificationAction } from "common/types"; 2 | import { GameClassification } from "common/butlerd/messages"; 3 | 4 | interface ClassificationActions { 5 | [key: string]: ClassificationAction; 6 | } 7 | 8 | export const classificationActions = { 9 | [GameClassification.Game]: "launch", 10 | [GameClassification.Tool]: "launch", 11 | 12 | [GameClassification.Assets]: "open", 13 | [GameClassification.GameMod]: "open", 14 | [GameClassification.PhysicalGame]: "open", 15 | [GameClassification.Soundtrack]: "open", 16 | [GameClassification.Other]: "open", 17 | [GameClassification.Comic]: "open", 18 | [GameClassification.Book]: "open", 19 | } as ClassificationActions; 20 | -------------------------------------------------------------------------------- /src/common/constants/colors.ts: -------------------------------------------------------------------------------- 1 | export const codGray = "#141414"; 2 | export const darkMineShaft = "#2E2B2C"; 3 | export const lightMineShaft = "#383434"; 4 | 5 | export const spooky = "#ff713e"; 6 | export const spookyLight = "#ff9c6d"; 7 | 8 | export const carnation = "#fa5c5c"; 9 | export const vividTangerine = "#ff8080"; 10 | -------------------------------------------------------------------------------- /src/common/constants/default-manifest-icons.ts: -------------------------------------------------------------------------------- 1 | interface ManifestIcons { 2 | [key: string]: string; 3 | } 4 | 5 | export default { 6 | play: "play2", 7 | editor: "pencil", 8 | manual: "lifebuoy", 9 | forums: "users", 10 | } as ManifestIcons; 11 | -------------------------------------------------------------------------------- /src/common/constants/net.ts: -------------------------------------------------------------------------------- 1 | // this session doesn't cache - see preboot for setup 2 | export const NET_PARTITION_NAME = "itch-zone"; 3 | 4 | // in milliseconds 5 | export const NET_TIMEOUT_MS = 10 * 1000; 6 | -------------------------------------------------------------------------------- /src/common/constants/platform-data.ts: -------------------------------------------------------------------------------- 1 | import { Architectures } from "common/butlerd/messages"; 2 | 3 | interface PlatformData { 4 | icon: string; 5 | platform: string; 6 | emoji: string; 7 | } 8 | 9 | interface PlatformDataMap { 10 | windows: PlatformData; 11 | linux: PlatformData; 12 | osx: PlatformData; 13 | [key: string]: PlatformData; 14 | } 15 | 16 | const data: PlatformDataMap = { 17 | windows: { icon: "windows8", platform: "windows", emoji: "🏁" }, 18 | linux: { icon: "tux", platform: "linux", emoji: "🐧" }, 19 | osx: { icon: "apple", platform: "osx", emoji: "🍎" }, 20 | }; 21 | export default data; 22 | 23 | export type PlatformHolder = { 24 | platforms: { 25 | windows: Architectures; 26 | linux: Architectures; 27 | osx: Architectures; 28 | }; 29 | type: "html" | any; 30 | }; 31 | 32 | export function hasPlatforms(target: PlatformHolder): boolean { 33 | for (const key of Object.keys(data)) { 34 | if ((target.platforms as { [key: string]: Architectures })[key]) { 35 | return true; 36 | } 37 | } 38 | if (target.type === "html") { 39 | return true; 40 | } 41 | return false; 42 | } 43 | -------------------------------------------------------------------------------- /src/common/constants/search-examples.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | "capsule", 3 | "race", 4 | "princess", 5 | "royals", 6 | "night", 7 | "garden", 8 | "tiger", 9 | "robot", 10 | "kenney", 11 | "quest", 12 | "simulator", 13 | "date", 14 | "lost", 15 | "tycoon", 16 | "grow", 17 | "fly", 18 | "basket", 19 | "legend", 20 | "drift", 21 | "galaxy", 22 | "rogue", 23 | "moon", 24 | "strategy", 25 | "shoot", 26 | "platform", 27 | "ghost", 28 | "tea", 29 | "parable", 30 | "sun", 31 | "market", 32 | "creator", 33 | "ville", 34 | "heart", 35 | "poem", 36 | "twine", // haters gonna hate 37 | "flower", 38 | "escape", 39 | "castle", 40 | "wild", 41 | "trouble", 42 | "purpose", 43 | "ludum dare", 44 | "challenge", 45 | "coop", 46 | "club", 47 | "dream", 48 | "midnight", 49 | "remix", 50 | "witch", 51 | "social", 52 | "hylics", 53 | "doggest", 54 | "crypt", 55 | "tower", 56 | "forest", 57 | ]; 58 | -------------------------------------------------------------------------------- /src/common/constants/urls.ts: -------------------------------------------------------------------------------- 1 | const originalItchio = "https://itch.io"; 2 | const itchio = process.env.WHEN_IN_ROME || originalItchio; 3 | const manual = "https://itch.io/docs/itch"; 4 | const itchRepo = "https://github.com/itchio/itch"; 5 | 6 | export const ITCH_URL_RE = /^itch:/i; 7 | 8 | export default { 9 | itchRepo, 10 | originalItchio, 11 | itchio, 12 | appHomepage: "https://itch.io/app", 13 | itchTranslationPlatform: "https://weblate.itch.zone", 14 | brothRepo: "https://broth.itch.zone", 15 | remoteLocalePath: "https://locales.itch.zone/itch", 16 | manual, 17 | 18 | itchioApi: itchio, 19 | termsOfService: `${itchio}/docs/legal/terms`, 20 | twoFactorHelp: `${itchio}/docs/advanced/two-factor-authentication`, 21 | accountRegister: `${itchio}/register`, 22 | accountForgotPassword: `${itchio}/user/forgot-password`, 23 | developersLearnMore: `${itchio}/developers`, 24 | dashboard: `${itchio}/dashboard`, 25 | myCollections: `${itchio}/my-collections`, 26 | sandboxDocs: `${manual}/using/sandbox.html`, 27 | proxyDocs: `${manual}/using/proxy.html`, 28 | linuxSandboxSetup: `${manual}/using/sandbox/linux.html#one-time-setup`, 29 | windowsSandboxSetup: `${manual}/using/sandbox/windows.html#one-time-setup`, 30 | releasesPage: `${itchRepo}/releases`, 31 | installingOnLinux: `${manual}/installing/linux/`, 32 | windowsAntivirus: `${manual}/installing/windows.html#antivirus-software`, 33 | }; 34 | -------------------------------------------------------------------------------- /src/common/constants/windows.ts: -------------------------------------------------------------------------------- 1 | export interface WindowInitialParams { 2 | width?: number; 3 | height?: number; 4 | } 5 | 6 | const whitelist: { [key: string]: WindowInitialParams } = { 7 | "itch://downloads": {}, 8 | "itch://preferences": {}, 9 | "itch://applog": {}, 10 | "itch://scan-install-locations": { 11 | width: 700, 12 | height: 500, 13 | }, 14 | }; 15 | 16 | export function opensInWindow(url: string): WindowInitialParams { 17 | return whitelist[normalizeURL(url)]; 18 | } 19 | 20 | // Removes trailing slash in URL, if any 21 | export function normalizeURL(url: string): string { 22 | return url.replace(/\/$/, ""); 23 | } 24 | -------------------------------------------------------------------------------- /src/common/env.ts: -------------------------------------------------------------------------------- 1 | const isDev = (app) => { 2 | const isEnvSet = "ELECTRON_IS_DEV" in process.env; 3 | const getFromEnv = Number.parseInt(process.env.ELECTRON_IS_DEV, 10) === 1; 4 | return isEnvSet ? getFromEnv : !app.isPackaged; 5 | }; 6 | 7 | const isCanary = (app) => app.getName() === "kitch"; 8 | 9 | const envName = (app) => 10 | process.env.NODE_ENV || (isDev(app) ? "development" : "production"); 11 | 12 | const setNodeEnv = (app) => { 13 | process.env[["NODE", "ENV"].join("_")] = envName(app); 14 | }; 15 | 16 | export default { 17 | isCanary, 18 | setNodeEnv, 19 | integrationTests: !!process.env.ITCH_INTEGRATION_TESTS, 20 | unitTests: false, 21 | channel: (app) => (isCanary(app) ? "canary" : "stable"), 22 | appName: (app) => (isCanary(app) ? "kitch" : "itch"), 23 | development: (app) => envName(app) === "development", 24 | production: (app) => envName(app) === "production", 25 | }; 26 | -------------------------------------------------------------------------------- /src/common/format/camelify.ts: -------------------------------------------------------------------------------- 1 | import { isDate } from "underscore"; 2 | 3 | // regexps are generally slow, 4 | export function camelify(str: string): string { 5 | return str.replace(/_[a-z]/g, (x) => x[1].toUpperCase()); 6 | } 7 | 8 | export function camelifyObject(obj: any): any { 9 | if (obj && typeof obj === "object") { 10 | if (Array.isArray(obj)) { 11 | const res = Array(obj.length); 12 | for (let i = 0; i < obj.length; i++) { 13 | res[i] = camelifyObject(obj[i]); 14 | } 15 | return res; 16 | } else if (isDate(obj)) { 17 | return obj; 18 | } else { 19 | const keys = Object.keys(obj); 20 | if (keys.length === 0) { 21 | return obj; 22 | } 23 | 24 | const res: any = {}; 25 | for (const key of keys) { 26 | res[camelify(key)] = camelifyObject(obj[key]); 27 | } 28 | return res; 29 | } 30 | } else { 31 | return obj; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/common/format/exit-code.ts: -------------------------------------------------------------------------------- 1 | export function formatExitCode(code: number): string { 2 | const dec = code.toString(10); 3 | const hex = (code.toString(16) as any).padStart(8, "0"); 4 | return `${dec} (0x${hex})`; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/format/filesize.ts: -------------------------------------------------------------------------------- 1 | // ported from https://github.com/dustin/go-humanize 2 | 3 | // Si vis pacem, para bellum 4 | const sizes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; 5 | const base = 1024; 6 | const logE1024 = Math.log(base); 7 | 8 | export function fileSize(s: number) { 9 | if (s < 10) { 10 | return `${s} B`; 11 | } 12 | 13 | const e = Math.floor(Math.log(s) / logE1024); 14 | const suffix = sizes[Math.trunc(e)]; 15 | const val = Math.floor((s / Math.pow(base, e)) * 10 + 0.5) / 10; 16 | if (val < 10) { 17 | return `${val.toFixed(1)} ${suffix}`; 18 | } else { 19 | return `${val.toFixed(0)} ${suffix}`; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/common/format/platform.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "common/butlerd/messages"; 2 | 3 | const platforms = { 4 | linux: "Linux", // we hardly GNU you 5 | windows: "Windows", 6 | osx: "macOS", // since WWDC june 2016 7 | android: "Android", 8 | }; 9 | 10 | /** 11 | * Formats a platform for humans to read. 12 | */ 13 | export function formatPlatform(p: Platform): string { 14 | return (platforms as any)[p] || p; 15 | } 16 | 17 | export function formatArch(arch: string): string { 18 | switch (arch) { 19 | case "ia32": 20 | return "32-bit"; 21 | case "x64": 22 | return "64-bit"; 23 | default: 24 | return arch; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/common/format/price.ts: -------------------------------------------------------------------------------- 1 | export function formatPrice(currency: string, value: number) { 2 | if (currency === "CAD") { 3 | return `CAD $${(value / 100).toFixed(2)}`; 4 | } else if (currency === "AUD") { 5 | return `AUD $${(value / 100).toFixed(2)}`; 6 | } else if (currency === "GBP") { 7 | return `£${(value / 100).toFixed(2)}`; 8 | } else if (currency === "JPY") { 9 | return `¥${value.toFixed(2)}`; 10 | } else if (currency === "EUR") { 11 | return `${(value / 100).toFixed(2)} €`; 12 | } else { 13 | // default to dollarydoos 14 | return `$${(value / 100).toFixed(2)}`; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/common/format/shape.ts: -------------------------------------------------------------------------------- 1 | interface Shape { 2 | [key: string]: Shape | boolean; 3 | } 4 | 5 | export function fillShape(input: any, shape: Shape): any { 6 | if (!input) { 7 | return input; 8 | } 9 | if (Array.isArray(input)) { 10 | const arr = input as any[]; 11 | return arr.map((subInput) => fillShape(subInput, shape)); 12 | } 13 | const keys = Object.keys(shape); 14 | let output: any = {}; 15 | if (keys.length === 1 && keys[0] === "*") { 16 | const subShape = shape[keys[0]] as Shape; 17 | for (const k of Object.keys(input)) { 18 | output[k] = fillShape(input[k], subShape); 19 | } 20 | return output; 21 | } 22 | 23 | for (const k of keys) { 24 | const v = shape[k]; 25 | if (v === true) { 26 | output[k] = input[k]; 27 | } else { 28 | output[k] = fillShape(input[k], v as Shape); 29 | } 30 | } 31 | return output; 32 | } 33 | -------------------------------------------------------------------------------- /src/common/format/show-in-explorer.ts: -------------------------------------------------------------------------------- 1 | import { LocalizedString } from "common/types"; 2 | 3 | export function showInExplorerString(): LocalizedString { 4 | switch (process.platform) { 5 | case "linux": { 6 | return ["grid.item.open_file_location.linux"]; 7 | } 8 | case "darwin": { 9 | return ["grid.item.open_file_location.osx"]; 10 | } 11 | case "win32": 12 | default: { 13 | return ["grid.item.open_file_location.windows"]; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/common/format/slugify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a version of input with only [a-zA-Z_ ] and 3 | * collapsed whitespace. 4 | */ 5 | export function slugify(input: string): string { 6 | return input 7 | .toLowerCase() 8 | .replace(/[^a-zA-Z_ ]/g, "") 9 | .replace(/ +/g, "_"); 10 | } 11 | -------------------------------------------------------------------------------- /src/common/format/t.ts: -------------------------------------------------------------------------------- 1 | import IntlMessageFormat from "intl-messageformat"; 2 | 3 | import { I18nState, LocalizedString } from "common/types"; 4 | 5 | const emptyObj = {}; 6 | 7 | /** 8 | * Returns the input if it's a string, or a localized message if 9 | * the input is in the form [i18nKeys, {i18nValue1: foo, i18nValue2: bar}?] 10 | */ 11 | export function t(i18n: I18nState, input: string | LocalizedString): string { 12 | if (!input) { 13 | return ""; 14 | } 15 | 16 | if (Array.isArray(input)) { 17 | if (input.length < 1) { 18 | return ""; 19 | } 20 | 21 | const { strings, lang } = i18n; 22 | const messages = strings[lang] || strings[lang.substring(0, 2)] || emptyObj; 23 | const enMessages = strings.en || emptyObj; 24 | const [key, values] = input; 25 | const message = 26 | messages[key] || 27 | enMessages[key] || 28 | (values && values.defaultValue) || 29 | key; 30 | const formatter = new IntlMessageFormat(message, lang); 31 | return collapseIntlChunks(formatter.format(values)); 32 | } else { 33 | return input; 34 | } 35 | } 36 | 37 | export function collapseIntlChunks(s: string | string[]): string { 38 | if (Array.isArray(s)) { 39 | return s.join(""); 40 | } else { 41 | return s; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/common/format/truncate.ts: -------------------------------------------------------------------------------- 1 | interface TruncateOpts { 2 | length: number; 3 | } 4 | 5 | /** 6 | * Returns a truncated version of input. The output length will not exceed 7 | * opts.length. 8 | */ 9 | export function truncate(input: string, opts: TruncateOpts): string { 10 | if (!input) { 11 | return input; 12 | } 13 | if (input.length > opts.length) { 14 | return input.substr(0, opts.length - 3) + "..."; 15 | } 16 | return input; 17 | } 18 | -------------------------------------------------------------------------------- /src/common/format/upload.ts: -------------------------------------------------------------------------------- 1 | import { Upload, Build } from "common/butlerd/messages"; 2 | 3 | export function formatUploadTitle(u: Upload): string { 4 | return u ? u.displayName || u.filename : "?"; 5 | } 6 | 7 | function clean(s: string) { 8 | return s 9 | .replace(/\.[a-zA-Z0-9]+$/, "") 10 | .replace(/[_-]/g, " ") 11 | .replace(/\s+/, " "); 12 | } 13 | 14 | export function formatUploadTitleFancy(u: Upload): string { 15 | if (u.displayName) { 16 | return u.displayName; 17 | } 18 | if (u.filename) { 19 | return clean(u.filename); 20 | } 21 | return "?"; 22 | } 23 | 24 | export function formatBuildVersionInfo(b: Build): string { 25 | if (!b) { 26 | return null; 27 | } 28 | 29 | if (b.userVersion) { 30 | return `v${b.userVersion}`; 31 | } 32 | return `#${b.version}`; 33 | } 34 | -------------------------------------------------------------------------------- /src/common/helpers/get-by-ids.ts: -------------------------------------------------------------------------------- 1 | import { size } from "underscore"; 2 | 3 | const emptyArr = [] as any[]; 4 | 5 | interface RecordMap { 6 | [key: string]: T; 7 | } 8 | 9 | function getByIds(records: RecordMap, ids: string[] | number[]): T[] { 10 | if (size(ids) === 0) { 11 | return emptyArr; 12 | } 13 | 14 | if (!records) { 15 | return emptyArr; 16 | } 17 | 18 | const result = []; 19 | for (const id of ids) { 20 | const record = records[id]; 21 | if (record) { 22 | result.push(record); 23 | } 24 | } 25 | return result; 26 | } 27 | 28 | export default getByIds; 29 | -------------------------------------------------------------------------------- /src/common/helpers/group-id-by.ts: -------------------------------------------------------------------------------- 1 | interface Record { 2 | id: any; 3 | } 4 | 5 | interface RecordMap { 6 | [id: string]: T; 7 | } 8 | 9 | interface Grouped { 10 | [key: string]: string[]; 11 | } 12 | 13 | interface Getter { 14 | (x: T): string; 15 | } 16 | 17 | const emptyArr: any[] = []; 18 | 19 | /** 20 | * Given: 21 | * [{id: 1, gameId: 10}, {id: 2, gameId: 20}, {id: 3, gameId: 20}] 22 | * This will give: 23 | * {"10": [1], "20": [2, 3]} 24 | */ 25 | function groupIdBy( 26 | records: RecordMap | T[], 27 | field: string | Getter 28 | ): Grouped { 29 | const result: Grouped = {}; 30 | 31 | const getter: Getter = 32 | typeof field === "string" ? (o: any) => o[field] : (field as Getter); 33 | 34 | if (!records) { 35 | // muffin 36 | } else if (Array.isArray(records)) { 37 | for (const record of records) { 38 | const index = getter(record); 39 | result[index] = [...(result[index] || emptyArr), record.id]; 40 | } 41 | } else { 42 | for (const recordKey of Object.keys(records)) { 43 | const record = records[recordKey]; 44 | const index = getter(record); 45 | result[index] = [...(result[index] || emptyArr), record.id]; 46 | } 47 | } 48 | return result; 49 | } 50 | 51 | export default groupIdBy; 52 | -------------------------------------------------------------------------------- /src/common/helpers/secret-click.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Secret clicks are used to reveal internal screens, for 3 | * example, performing a secret click on the main itch logo 4 | * opens secret settings. 5 | * Performing a secret click on a tab shows its data, on a game 6 | * shows its install info, etc. 7 | */ 8 | export function isSecretClick(ev: React.MouseEvent) { 9 | if (process.platform === "darwin") { 10 | // on macOS, ctrl+click is hard-wired to "secondary click", which 11 | // ends up being a context menu event, cf. 12 | // https://apple.stackexchange.com/questions/118276/disable-system-wide-ctrl-click-as-right-click-in-mavericks 13 | return ev.shiftKey && ev.metaKey; 14 | } else { 15 | return ev.shiftKey && ev.ctrlKey; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/common/helpers/spec-to-button.ts: -------------------------------------------------------------------------------- 1 | import { ModalButton, ModalButtonSpec } from "common/types"; 2 | 3 | interface DefaultButtons { 4 | [key: string]: ModalButton; 5 | ok: ModalButton; 6 | cancel: ModalButton; 7 | nevermind: ModalButton; 8 | } 9 | 10 | const DEFAULT_BUTTONS = { 11 | cancel: { 12 | id: "modal-cancel", 13 | label: ["prompt.action.cancel"], 14 | className: "secondary", 15 | left: true, 16 | }, 17 | nevermind: { 18 | id: "modal-cancel", 19 | label: ["prompt.action.nevermind"], 20 | className: "secondary", 21 | left: true, 22 | }, 23 | ok: { 24 | id: "modal-ok", 25 | label: ["prompt.action.ok"], 26 | className: "secondary", 27 | }, 28 | } as DefaultButtons; 29 | 30 | export function specToButton(buttonSpec: ModalButtonSpec): ModalButton { 31 | let button: ModalButton; 32 | if (typeof buttonSpec === "string") { 33 | button = DEFAULT_BUTTONS[buttonSpec]; 34 | if (!button) { 35 | button = { 36 | label: "?", 37 | }; 38 | } 39 | } else { 40 | button = buttonSpec as ModalButton; 41 | } 42 | return button; 43 | } 44 | -------------------------------------------------------------------------------- /src/common/helpers/to-tab-data.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/common/helpers/to-tab-data.ts -------------------------------------------------------------------------------- /src/common/helpers/with-timeout.ts: -------------------------------------------------------------------------------- 1 | export function withTimeout( 2 | label: string, 3 | millis: number, 4 | p: Promise 5 | ): Promise { 6 | return new Promise((resolve, reject) => { 7 | let timeout = setTimeout(() => { 8 | reject(new Error(`${label} timed out!`)); 9 | }, millis); 10 | 11 | p.then((v) => { 12 | clearTimeout(timeout); 13 | resolve(v); 14 | }).catch((err) => { 15 | clearTimeout(timeout); 16 | reject(err); 17 | }); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/common/ipc.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer, IpcRenderer, OpenDialogOptions } from "electron"; 2 | 3 | export type AsyncIpcHandlers = { 4 | showOpenDialog: (o: OpenDialogOptions) => Promise; 5 | getUserCacheSize: (n: number) => Promise; 6 | getGPUFeatureStatus: (x: undefined) => Promise; 7 | }; 8 | 9 | export type SyncIpcHandlers = { 10 | buildApp: (x: undefined) => { name: string; isPackaged: boolean }; 11 | userAgent: (x: undefined) => string; 12 | getImageURL: (p: string) => string; 13 | getInjectURL: (p: string) => string; 14 | onCaptchaResponse: (r: string) => null; 15 | legacyMarketPath: () => string; 16 | mainLogPath: () => string; 17 | }; 18 | 19 | export const emitSyncIpcEvent = ( 20 | eventName: K, 21 | arg: Parameters[0] 22 | ): ReturnType => { 23 | return ipcRenderer.sendSync(eventName, arg); 24 | }; 25 | 26 | export const emitAsyncIpcEvent = ( 27 | eventName: K, 28 | arg: Parameters[0] 29 | ): ReturnType => { 30 | return ipcRenderer.invoke(eventName, arg) as ReturnType; 31 | }; 32 | -------------------------------------------------------------------------------- /src/common/os/platform.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "common/butlerd/messages"; 2 | 3 | /** 4 | * Get platform in the format used by the itch.io API 5 | */ 6 | export function itchPlatform(): Platform { 7 | switch (process.platform) { 8 | case "darwin": 9 | return Platform.OSX; 10 | case "win32": 11 | return Platform.Windows; 12 | case "linux": 13 | return Platform.Linux; 14 | default: 15 | return Platform.Unknown; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/common/os/runtime.ts: -------------------------------------------------------------------------------- 1 | import { Runtime } from "common/types"; 2 | import { itchPlatform } from "common/os/platform"; 3 | 4 | let cachedRuntime: Runtime; 5 | 6 | export function currentRuntime(): Runtime { 7 | if (!cachedRuntime) { 8 | cachedRuntime = { 9 | platform: itchPlatform(), 10 | }; 11 | } 12 | 13 | return cachedRuntime; 14 | } 15 | -------------------------------------------------------------------------------- /src/common/reducers/all.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { RootState } from "common/types"; 3 | 4 | import system from "common/reducers/system"; 5 | import setup from "common/reducers/setup"; 6 | import profile from "common/reducers/profile"; 7 | import i18n from "common/reducers/i18n"; 8 | import ui from "common/reducers/ui"; 9 | import preferences from "common/reducers/preferences"; 10 | import tasks from "common/reducers/tasks"; 11 | import downloads from "common/reducers/downloads"; 12 | import status from "common/reducers/status"; 13 | import gameUpdates from "common/reducers/game-updates"; 14 | import commons from "common/reducers/commons"; 15 | import systemTasks from "common/reducers/system-tasks"; 16 | import broth from "common/reducers/broth"; 17 | import butlerd from "common/reducers/butlerd"; 18 | import winds from "common/reducers/winds"; 19 | 20 | const reducer = combineReducers({ 21 | system, 22 | setup, 23 | profile, 24 | i18n, 25 | ui, 26 | preferences, 27 | tasks, 28 | downloads, 29 | status, 30 | gameUpdates, 31 | commons, 32 | systemTasks, 33 | broth, 34 | butlerd, 35 | winds, 36 | }); 37 | export default reducer; 38 | -------------------------------------------------------------------------------- /src/common/reducers/butlerd.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | 4 | import { ButlerdState } from "common/types"; 5 | 6 | const initialState = { 7 | startedAt: null, 8 | endpoint: null, 9 | } as ButlerdState; 10 | 11 | export default reducer(initialState, (on) => { 12 | on(actions.gotButlerdEndpoint, (state, action) => { 13 | const { endpoint } = action.payload; 14 | return { ...state, endpoint }; 15 | }); 16 | 17 | on(actions.spinningUpButlerd, (state, action) => { 18 | const { startedAt } = action.payload; 19 | return { ...state, startedAt }; 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/common/reducers/commons.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | 4 | import { CommonsState } from "common/types"; 5 | 6 | const initialState: CommonsState = { 7 | downloadKeys: {}, 8 | downloadKeyIdsByGameId: {}, 9 | caves: {}, 10 | caveIdsByGameId: {}, 11 | locationSizes: {}, 12 | }; 13 | 14 | export default reducer(initialState, (on) => { 15 | // TODO: be much smarter+faster here. 16 | // this is a good place to dedupe updates if records 17 | // are deepEqual 18 | on(actions.commonsUpdated, (state, action) => { 19 | const data = action.payload; 20 | return { 21 | ...state, 22 | ...data, 23 | }; 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/common/reducers/derived-reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer, Action } from "redux"; 2 | 3 | function derived( 4 | reducer: Reducer, 5 | derivedReducer: (state: any) => any 6 | ): Reducer { 7 | return (state: T, action: Action) => { 8 | const reducerFields = reducer(state, action); 9 | if (state) { 10 | return { 11 | ...reducerFields, 12 | ...derivedReducer(reducerFields), 13 | }; 14 | } else { 15 | return reducerFields; 16 | } 17 | }; 18 | } 19 | 20 | export default derived; 21 | -------------------------------------------------------------------------------- /src/common/reducers/downloads.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | import { DownloadsState } from "common/types"; 4 | import { indexBy, map } from "underscore"; 5 | 6 | const SPEED_DATA_POINT_COUNT = 60; 7 | 8 | const initialState: DownloadsState = { 9 | speeds: map(new Array(SPEED_DATA_POINT_COUNT), (x) => 0), 10 | items: {}, 11 | progresses: {}, 12 | paused: true, 13 | }; 14 | 15 | export default reducer(initialState, (on) => { 16 | on(actions.downloadsListed, (state, action) => { 17 | const { downloads } = action.payload; 18 | return { 19 | ...state, 20 | items: indexBy(downloads, "id"), 21 | }; 22 | }); 23 | 24 | on(actions.downloadProgress, (state, action) => { 25 | const { download, progress, speedHistory } = action.payload; 26 | return { 27 | ...state, 28 | progresses: { 29 | ...state.progresses, 30 | [download.id]: progress, 31 | }, 32 | speeds: speedHistory, 33 | }; 34 | }); 35 | 36 | on(actions.setDownloadsPaused, (state, action) => { 37 | const { paused } = action.payload; 38 | return { 39 | ...state, 40 | paused, 41 | }; 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/common/reducers/game-updates.ts: -------------------------------------------------------------------------------- 1 | import { GameUpdatesState } from "common/types"; 2 | import { omit } from "underscore"; 3 | 4 | import { actions } from "common/actions"; 5 | import reducer from "common/reducers/reducer"; 6 | 7 | const initialState = { 8 | updates: {}, 9 | checking: false, 10 | progress: -1, 11 | } as GameUpdatesState; 12 | 13 | export default reducer(initialState, (on) => { 14 | on(actions.gameUpdateCheckStatus, (state, action) => { 15 | const { checking, progress } = action.payload; 16 | return { 17 | ...state, 18 | checking, 19 | progress, 20 | }; 21 | }); 22 | 23 | on(actions.gameUpdateAvailable, (state, action) => { 24 | const { update } = action.payload; 25 | 26 | return { 27 | ...state, 28 | updates: { 29 | ...state.updates, 30 | [update.caveId]: update, 31 | }, 32 | }; 33 | }); 34 | 35 | on(actions.queueGameUpdate, (state, action) => { 36 | const { update } = action.payload; 37 | 38 | return { 39 | ...state, 40 | updates: omit(state.updates, update.caveId), 41 | }; 42 | }); 43 | 44 | on(actions.snoozeCave, (state, action) => { 45 | const { caveId } = action.payload; 46 | 47 | return { 48 | ...state, 49 | updates: omit(state.updates, caveId), 50 | }; 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/common/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import allInitial from "common/reducers/all"; 2 | import { RootState, Action } from "common/types"; 3 | 4 | let all = allInitial; 5 | 6 | let extModule = module as typeof module & { hot?: { accept?: any } }; 7 | 8 | if (extModule.hot) { 9 | extModule.hot.accept("./all", () => { 10 | console.log(`Refreshing reducers...`); 11 | all = require("./all").default; 12 | }); 13 | } 14 | 15 | export default function reduce(rs: RootState, action: Action) { 16 | return all(rs, action); 17 | } 18 | -------------------------------------------------------------------------------- /src/common/reducers/preferences.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | 4 | import { PreferencesState } from "common/types"; 5 | import env from "common/env"; 6 | 7 | const OFFLINE_MODE = process.env.OFFLINE_MODE === "1"; 8 | 9 | export const initialState = { 10 | downloadSelfUpdates: true, 11 | offlineMode: OFFLINE_MODE, 12 | installLocations: {}, 13 | defaultInstallLocation: "appdata", 14 | isolateApps: false, 15 | closeToTray: true, 16 | readyNotification: true, 17 | showAdvanced: false, 18 | openAtLogin: false, 19 | openAsHidden: false, 20 | manualGameUpdates: false, 21 | preventDisplaySleep: true, 22 | preferOptimizedPatches: false, 23 | disableBrowser: env.integrationTests ? true : false, 24 | enableTabs: false, 25 | } as PreferencesState; 26 | 27 | export default reducer(initialState, (on) => { 28 | on(actions.updatePreferences, (state, action) => { 29 | const record = action.payload; 30 | return { 31 | ...state, 32 | ...record, 33 | }; 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/common/reducers/profile/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import login from "common/reducers/profile/login"; 3 | import profile from "common/reducers/profile/profile"; 4 | import itchioUris from "common/reducers/profile/itchio-uris"; 5 | 6 | import { Reducer } from "redux"; 7 | import { ProfileState } from "common/types"; 8 | 9 | const reducers = { 10 | login, 11 | profile, 12 | itchioUris, 13 | }; 14 | 15 | export default combineReducers(reducers) as Reducer; 16 | -------------------------------------------------------------------------------- /src/common/reducers/profile/itchio-uris.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | 4 | const initialState: string[] = []; 5 | 6 | export default reducer(initialState, (on) => { 7 | on(actions.pushItchioURI, (state, action) => { 8 | const { uri } = action.payload; 9 | return [...state, uri]; 10 | }); 11 | 12 | on(actions.clearItchioURIs, (state, action) => { 13 | return []; 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/common/reducers/profile/login.ts: -------------------------------------------------------------------------------- 1 | import { ProfileLoginState } from "common/types"; 2 | import { actions } from "common/actions"; 3 | import reducer from "common/reducers/reducer"; 4 | 5 | const initialState = { 6 | errors: [], 7 | blockingOperation: null, 8 | } as ProfileLoginState; 9 | 10 | export default reducer(initialState, (on) => { 11 | on(actions.attemptLogin, (state, action) => { 12 | return { 13 | ...state, 14 | errors: [], 15 | blockingOperation: { 16 | icon: "heart-filled", 17 | message: ["login.status.login"], 18 | bps: 0, 19 | eta: 0, 20 | }, 21 | }; 22 | }); 23 | 24 | on(actions.loginFailed, (state, action) => { 25 | const { error, username } = action.payload; 26 | 27 | return { 28 | ...initialState, 29 | error, 30 | blockingOperation: null, 31 | lastUsername: username, 32 | }; 33 | }); 34 | 35 | on(actions.loginCancelled, (state, action) => { 36 | return { 37 | ...state, 38 | blockingOperation: null, 39 | picking: false, 40 | }; 41 | }); 42 | 43 | on(actions.loginSucceeded, (state, action) => { 44 | return initialState; 45 | }); 46 | 47 | on(actions.loggedOut, (state, action) => { 48 | return initialState; 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/common/reducers/profile/profile.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | import { Profile } from "common/butlerd/messages"; 4 | 5 | const initialState = null as Profile; 6 | 7 | export default reducer(initialState, (on) => { 8 | on(actions.loginSucceeded, (state, action) => { 9 | const { profile } = action.payload; 10 | return profile; 11 | }); 12 | 13 | on(actions.loggedOut, (state, action) => { 14 | return initialState; 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/common/reducers/setup.ts: -------------------------------------------------------------------------------- 1 | import { SetupState } from "common/types"; 2 | import { actions } from "common/actions"; 3 | import reducer from "common/reducers/reducer"; 4 | 5 | const initialState = { 6 | done: false, 7 | errors: [], 8 | blockingOperation: { 9 | icon: "moon", 10 | message: ["login.status.dependency_check"], 11 | }, 12 | } as SetupState; 13 | 14 | export default reducer(initialState, (on) => { 15 | on(actions.setupStatus, (state, action) => { 16 | const blockingOperation = action.payload; 17 | return { 18 | ...state, 19 | errors: [], 20 | blockingOperation, 21 | }; 22 | }); 23 | 24 | on(actions.setupOperationProgress, (state, action) => { 25 | let { blockingOperation } = state; 26 | const { progress } = action.payload; 27 | if (blockingOperation) { 28 | blockingOperation = { 29 | ...blockingOperation, 30 | progressInfo: progress, 31 | }; 32 | } 33 | return { 34 | ...state, 35 | blockingOperation, 36 | }; 37 | }); 38 | 39 | on(actions.setupDone, (state, action) => { 40 | return { 41 | ...state, 42 | done: true, 43 | errors: [], 44 | blockingOperation: null, 45 | }; 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/common/reducers/status.ts: -------------------------------------------------------------------------------- 1 | import { StatusState } from "common/types"; 2 | import reducer from "common/reducers/reducer"; 3 | 4 | import { rest } from "underscore"; 5 | import { actions } from "common/actions"; 6 | 7 | const initialState = { 8 | messages: [], 9 | openAtLoginError: null, 10 | reduxLoggingEnabled: false, 11 | } as StatusState; 12 | 13 | export default reducer(initialState, (on) => { 14 | on(actions.statusMessage, (state, action) => { 15 | const { message } = action.payload; 16 | 17 | return { 18 | ...state, 19 | messages: [message, ...state.messages], 20 | }; 21 | }); 22 | 23 | on(actions.dismissStatusMessage, (state, action) => { 24 | return { 25 | ...state, 26 | messages: rest(state.messages), 27 | }; 28 | }); 29 | 30 | on(actions.openAtLoginError, (state, action) => { 31 | const error = action.payload; 32 | return { 33 | ...state, 34 | openAtLoginError: error, 35 | }; 36 | }); 37 | 38 | on(actions.setReduxLoggingEnabled, (state, action) => { 39 | const { enabled } = action.payload; 40 | return { 41 | ...state, 42 | reduxLoggingEnabled: enabled, 43 | }; 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/common/reducers/system-tasks.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | 4 | import { SystemTasksState } from "common/types"; 5 | 6 | const seconds = 1000; 7 | 8 | const initialState = { 9 | nextComponentsUpdateCheck: Date.now() + 15 * seconds, 10 | nextGameUpdateCheck: Date.now() + 30 * seconds, 11 | } as SystemTasksState; 12 | 13 | export default reducer(initialState, (on) => { 14 | on(actions.scheduleSystemTask, (state, action) => { 15 | const { payload } = action; 16 | return { 17 | ...state, 18 | ...payload, 19 | }; 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/common/reducers/ui/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import menu from "common/reducers/ui/menu"; 4 | import search from "common/reducers/ui/search"; 5 | 6 | import { Reducer } from "redux"; 7 | 8 | import { UIState } from "common/types"; 9 | 10 | export default combineReducers({ 11 | menu, 12 | search, 13 | }) as Reducer; 14 | -------------------------------------------------------------------------------- /src/common/reducers/ui/menu.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | 4 | import { UIMenuState } from "common/types"; 5 | 6 | const initialState = { 7 | template: [], 8 | } as UIMenuState; 9 | 10 | export default reducer(initialState, (on) => { 11 | on(actions.menuChanged, (state, action) => { 12 | const { template } = action.payload; 13 | return { template }; 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/common/reducers/ui/search.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import reducer from "common/reducers/reducer"; 3 | 4 | import { UISearchState } from "common/types"; 5 | 6 | const initialState = { 7 | open: false, 8 | } as UISearchState; 9 | 10 | export default reducer(initialState, (on) => { 11 | on(actions.searchVisibilityChanged, (state, action) => { 12 | const { open } = action.payload; 13 | return { ...state, open }; 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/common/reducers/wind/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import properties from "common/reducers/wind/properties"; 4 | import modals from "common/reducers/wind/modals"; 5 | import tabInstances from "common/reducers/wind/tab-instances"; 6 | import navigation from "common/reducers/wind/navigation"; 7 | import native from "common/reducers/wind/native"; 8 | import { WindState } from "common/types"; 9 | 10 | export default combineReducers({ 11 | properties, 12 | modals, 13 | tabInstances, 14 | navigation, 15 | native, 16 | }); 17 | -------------------------------------------------------------------------------- /src/common/reducers/wind/modals.ts: -------------------------------------------------------------------------------- 1 | import { reject } from "underscore"; 2 | 3 | import { ModalsState } from "common/types"; 4 | 5 | import { actions } from "common/actions"; 6 | import reducer from "common/reducers/reducer"; 7 | 8 | const initialState: ModalsState = []; 9 | 10 | export default reducer(initialState, (on) => { 11 | on(actions.openModal, (state, action) => { 12 | const modal = action.payload; 13 | return [modal, ...state]; 14 | }); 15 | 16 | on(actions.updateModalWidgetParams, (state, action) => { 17 | const { id, widgetParams } = action.payload; 18 | return state.map((modal) => { 19 | if (modal.id === id) { 20 | return { ...modal, widgetParams }; 21 | } 22 | return modal; 23 | }); 24 | }); 25 | 26 | on(actions.modalClosed, (state, action) => { 27 | const { id } = action.payload; 28 | return reject(state, (modal) => modal.id === id); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/common/reducers/wind/native.ts: -------------------------------------------------------------------------------- 1 | import { NativeWindowState } from "common/types"; 2 | 3 | import { actions } from "common/actions"; 4 | import reducer from "common/reducers/reducer"; 5 | 6 | const initialState = { 7 | id: null, 8 | focused: false, 9 | fullscreen: false, 10 | htmlFullscreen: false, 11 | maximized: false, 12 | } as NativeWindowState; 13 | 14 | export default reducer(initialState, (on) => { 15 | on(actions.windOpened, (state, action) => { 16 | const { nativeId } = action.payload; 17 | return { ...state, id: nativeId }; 18 | }); 19 | 20 | on(actions.windDestroyed, (state, action) => { 21 | return { ...state, id: null, focused: false }; 22 | }); 23 | 24 | on(actions.windFocusChanged, (state, action) => { 25 | const { focused } = action.payload; 26 | return { ...state, focused }; 27 | }); 28 | 29 | on(actions.windFullscreenChanged, (state, action) => { 30 | const { fullscreen } = action.payload; 31 | return { ...state, fullscreen }; 32 | }); 33 | 34 | on(actions.windHtmlFullscreenChanged, (state, action) => { 35 | const { htmlFullscreen } = action.payload; 36 | return { ...state, htmlFullscreen }; 37 | }); 38 | 39 | on(actions.windMaximizedChanged, (state, action) => { 40 | const { maximized } = action.payload; 41 | return { ...state, maximized }; 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/common/reducers/wind/properties.ts: -------------------------------------------------------------------------------- 1 | import { WindPropertiesState } from "common/types"; 2 | import { actions } from "common/actions"; 3 | import reducer from "common/reducers/reducer"; 4 | 5 | const initialState: WindPropertiesState = { 6 | initialURL: "", 7 | role: null, 8 | }; 9 | 10 | export default reducer(initialState, (on) => { 11 | on(actions.windOpened, (state, action) => { 12 | const { initialURL, role } = action.payload; 13 | return { initialURL, role }; 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/common/types/net.ts: -------------------------------------------------------------------------------- 1 | export type HTTPMethod = "head" | "get" | "post" | "put" | "patch" | "delete"; 2 | 3 | export type RequestFunc = ( 4 | method: HTTPMethod, 5 | uri: string, 6 | data: any, 7 | opts?: RequestOpts 8 | ) => Promise; 9 | 10 | export interface Headers { 11 | [key: string]: string[]; 12 | } 13 | 14 | export interface Response { 15 | statusCode: number; 16 | status: string; 17 | body: any; 18 | size: number; 19 | headers: Headers; 20 | } 21 | 22 | interface RequestCallback { 23 | (res: Response): void; 24 | } 25 | 26 | export interface RequestOpts { 27 | sink?: () => NodeJS.WritableStream; 28 | cb?: RequestCallback; 29 | format?: "json" | null; 30 | } 31 | -------------------------------------------------------------------------------- /src/common/types/sf.ts: -------------------------------------------------------------------------------- 1 | export interface ReadFileOpts { 2 | encoding: "utf8" | null; 3 | flag?: string; 4 | } 5 | 6 | export interface WriteFileOpts extends ReadFileOpts { 7 | mode?: number; 8 | } 9 | 10 | export interface FSError { 11 | code?: string; 12 | message: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/common/util/action-for-game.ts: -------------------------------------------------------------------------------- 1 | import { ClassificationAction } from "common/types"; 2 | 3 | import { Game, CaveSummary } from "common/butlerd/messages"; 4 | import { classificationActions } from "common/constants/classification-actions"; 5 | 6 | /** 7 | * Returns whether a game can be "launched" or "opened", where "launching" means 8 | * starting an executable, serving a web game, etc., and "opening" means showing files 9 | * in a file explorer. 10 | */ 11 | export function actionForGame( 12 | game: Game, 13 | cave: CaveSummary | null 14 | ): ClassificationAction { 15 | // FIXME: we're not using the cave at all here - we probably should. 16 | return classificationActions[game.classification] || "launch"; 17 | } 18 | -------------------------------------------------------------------------------- /src/common/util/debounce.ts: -------------------------------------------------------------------------------- 1 | class CancelError extends Error { 2 | constructor() { 3 | super(""); 4 | } 5 | 6 | toString() { 7 | return `CancelError: ${this.message}`; 8 | } 9 | } 10 | 11 | function debounce( 12 | f: (arg1: Arg1) => Promise, 13 | ms: number 14 | ): (arg1: Arg1) => Promise; 15 | 16 | function debounce( 17 | f: (arg1: Arg1, arg2: Arg2) => Promise, 18 | ms: number 19 | ): (arg1: Arg1, arg2: Arg2) => Promise; 20 | 21 | function debounce(f: (...args: any[]) => Promise, ms: number) { 22 | let rejectOther: ((err: Error) => void) | null; 23 | 24 | return async function (...args: any[]) { 25 | try { 26 | if (rejectOther) { 27 | rejectOther(new CancelError()); 28 | rejectOther = null; 29 | } 30 | await new Promise((resolve, reject) => { 31 | rejectOther = reject; 32 | setTimeout(resolve, ms); 33 | }); 34 | 35 | const ret = await f(...args); 36 | rejectOther = null; 37 | return ret; 38 | } catch (e) { 39 | if (e instanceof CancelError) { 40 | } else { 41 | throw e; 42 | } 43 | } 44 | return undefined as any; 45 | }; 46 | } 47 | 48 | export default debounce; 49 | -------------------------------------------------------------------------------- /src/common/util/frame-name-for-tab.ts: -------------------------------------------------------------------------------- 1 | export function frameNameForTab(wind: string, tab: string) { 2 | return `itch-desktop-app-wind_${wind}-tab_${tab}`; 3 | } 4 | -------------------------------------------------------------------------------- /src/common/util/lru-memoize.ts: -------------------------------------------------------------------------------- 1 | import fastMemoize from "fast-memoize"; 2 | import LRU from "lru-cache"; 3 | 4 | export function memoize(limit: number, f: T): T { 5 | return fastMemoize(f, { 6 | cache: { 7 | create: () => new LRU(limit), 8 | }, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/common/util/net.ts: -------------------------------------------------------------------------------- 1 | export function getResponseHeader( 2 | responseHeaders: Record | undefined, 3 | headerName: string 4 | ): string | null { 5 | if (!responseHeaders) { 6 | return null; 7 | } 8 | 9 | let value = responseHeaders[headerName]; 10 | // `string` type 11 | if (typeof value === "string") { 12 | return value; 13 | } 14 | 15 | // `string[]` type 16 | if (typeof value === "object" && typeof value.length === "number") { 17 | return value[0]; 18 | } 19 | 20 | return null; 21 | } 22 | -------------------------------------------------------------------------------- /src/common/util/partition-for-user.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the Electron partition for a given itch.io user 3 | */ 4 | export function partitionForUser(userId: string): string { 5 | return `persist:itchio-${userId || "anonymous"}`; 6 | } 7 | 8 | export function partitionForApp(): string { 9 | return `persist:itch-app`; 10 | } 11 | -------------------------------------------------------------------------------- /src/common/util/route.ts: -------------------------------------------------------------------------------- 1 | import { Store, isCancelled, Action } from "common/types"; 2 | 3 | import { Watcher } from "common/util/watcher"; 4 | 5 | import { Logger } from "common/logger"; 6 | 7 | const emptyArr = [] as any[]; 8 | 9 | function err(logger: Logger, e: Error, action: Action) { 10 | if (isCancelled(e)) { 11 | console.warn(`reactor for ${action.type} was cancelled`); 12 | } else { 13 | const actionName = (action || { type: "?" }).type; 14 | const errorStack = e.stack || e; 15 | const msg = `while reacting to ${actionName}: ${errorStack}`; 16 | logger.error(msg); 17 | } 18 | } 19 | 20 | function route(watcher: Watcher, store: Store, action: Action): void { 21 | setTimeout(() => { 22 | let promises = []; 23 | 24 | for (const r of watcher.reactors[action.type] || emptyArr) { 25 | promises.push(r(store, action)); 26 | } 27 | 28 | for (const sub of watcher.subs) { 29 | if (!sub) { 30 | continue; 31 | } 32 | 33 | for (const r of sub.reactors[action.type] || emptyArr) { 34 | promises.push(r(store, action)); 35 | } 36 | } 37 | Promise.all(promises).catch((e) => err(watcher.logger, e, action)); 38 | }, 0); 39 | return; 40 | } 41 | 42 | export default route; 43 | -------------------------------------------------------------------------------- /src/common/util/should-log-action.ts: -------------------------------------------------------------------------------- 1 | const noiseRe = /(window|tick|locale|Progress|commons)/; 2 | 3 | function shouldLogAction(action: any): boolean { 4 | return !noiseRe.test(action.type); 5 | } 6 | export default shouldLogAction; 7 | -------------------------------------------------------------------------------- /src/common/util/uuid.ts: -------------------------------------------------------------------------------- 1 | function v4(rng) { 2 | const rnds = rng(); 3 | 4 | // Per 4.4, set bits for version and `clock_seq_hi_and_reserved` 5 | rnds[6] = (rnds[6] & 0x0f) | 0x40; 6 | rnds[8] = (rnds[8] & 0x3f) | 0x80; 7 | 8 | return bytesToUuid(rnds); 9 | } 10 | 11 | export default v4; 12 | 13 | /** 14 | * Convert array of 16 byte values to UUID string format of the form: 15 | * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX 16 | */ 17 | const bth: string[] = []; 18 | for (let i = 0; i < 256; ++i) { 19 | bth[i] = (i + 0x100).toString(16).substr(1); 20 | } 21 | 22 | function bytesToUuid(buf: Uint8Array) { 23 | let i = 0; 24 | return ( 25 | bth[buf[i++]] + 26 | bth[buf[i++]] + 27 | bth[buf[i++]] + 28 | bth[buf[i++]] + 29 | "-" + 30 | bth[buf[i++]] + 31 | bth[buf[i++]] + 32 | "-" + 33 | bth[buf[i++]] + 34 | bth[buf[i++]] + 35 | "-" + 36 | bth[buf[i++]] + 37 | bth[buf[i++]] + 38 | "-" + 39 | bth[buf[i++]] + 40 | bth[buf[i++]] + 41 | bth[buf[i++]] + 42 | bth[buf[i++]] + 43 | bth[buf[i++]] + 44 | bth[buf[i++]] 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/main/boot/test-paths.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { resolve } from "path"; 3 | import { mkdirSync } from "original-fs"; 4 | 5 | const electronLocations = [ 6 | "home", 7 | "appData", 8 | "userData", 9 | "temp", 10 | "desktop", 11 | "documents", 12 | "downloads", 13 | "music", 14 | "pictures", 15 | "videos", 16 | ]; 17 | 18 | // override paths for tests so we know what we're dealing with 19 | export function setup() { 20 | const base = "./tmp/prefix"; 21 | 22 | for (const name of electronLocations) { 23 | const location = resolve(base, name); 24 | try { 25 | mkdirSync(location, { recursive: true }); 26 | app.setPath(name, location); 27 | } catch (e) { 28 | console.warn(`Could not set location ${name} to ${location}: ${e.stack}`); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/broth/manager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Package, 3 | PackageLike, 4 | EnsureOpts, 5 | UpgradeOpts, 6 | } from "main/broth/package"; 7 | import { join } from "path"; 8 | 9 | import { Store } from "common/types"; 10 | import { app } from "electron"; 11 | import { actions } from "common/actions"; 12 | import { SelfPackage } from "main/broth/self-package"; 13 | import env from "main/env"; 14 | 15 | const regularPackageNames = ["butler", "itch-setup"]; 16 | const packageNames = [env.appName, ...regularPackageNames]; 17 | 18 | export class Manager { 19 | private pkgs: PackageLike[] = []; 20 | private prefix: string; 21 | 22 | constructor(store: Store) { 23 | this.prefix = join(app.getPath("userData"), "broth"); 24 | 25 | store.dispatch(actions.packagesListed({ packageNames })); 26 | this.pkgs.push(new SelfPackage(store, env.appName)); 27 | for (const name of regularPackageNames) { 28 | this.pkgs.push(new Package(store, this.prefix, name)); 29 | } 30 | } 31 | 32 | async ensure(opts: EnsureOpts) { 33 | for (const pkg of this.pkgs) { 34 | await pkg.ensure(opts); 35 | } 36 | } 37 | 38 | async upgrade(opts: UpgradeOpts) { 39 | for (const pkg of this.pkgs) { 40 | await pkg.upgrade(opts); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/broth/platform.ts: -------------------------------------------------------------------------------- 1 | import { arch } from "main/os/arch"; 2 | 3 | /** platform in go format */ 4 | function goos(): string { 5 | let result = process.platform; 6 | if (result === "win32") { 7 | return "windows"; 8 | } 9 | return result; 10 | } 11 | 12 | /** arch in go format */ 13 | function goarch() { 14 | let result = arch(); 15 | if (result === "x64") { 16 | return "amd64"; 17 | } else if (result === "ia32") { 18 | return "386"; 19 | } else { 20 | return "unknown"; 21 | } 22 | } 23 | 24 | export function platformString(): string { 25 | const os = goos(); 26 | let arch: string; 27 | if (os === "darwin") { 28 | arch = "amd64"; 29 | } else { 30 | arch = goarch(); 31 | } 32 | return `${os}-${arch}`; 33 | } 34 | -------------------------------------------------------------------------------- /src/main/butlerd/mcall.ts: -------------------------------------------------------------------------------- 1 | import { RequestCreator } from "butlerd"; 2 | import { call, SetupFunc } from "common/butlerd/net"; 3 | import store from "main/store"; 4 | import { mainLogger } from "main/logger"; 5 | 6 | if (process.type !== "browser") { 7 | throw new Error(`mcall cannot be required from renderer process`); 8 | } 9 | 10 | const logger = mainLogger.childWithName("mcall"); 11 | 12 | /** 13 | * Perform a butlerd call from the main process 14 | */ 15 | export async function mcall( 16 | rc: RequestCreator, 17 | params: {} & Params, 18 | setup?: SetupFunc 19 | ): Promise { 20 | return await call(store, logger, rc, params, setup); 21 | } 22 | -------------------------------------------------------------------------------- /src/main/env.ts: -------------------------------------------------------------------------------- 1 | import env from "common/env"; 2 | import { app } from "electron"; 3 | 4 | export default { 5 | ...env, 6 | setNodeEnv: () => env.setNodeEnv(app), 7 | isCanary: env.isCanary(app), 8 | channel: env.channel(app), 9 | appName: env.appName(app), 10 | development: env.development(app), 11 | production: env.production(app), 12 | }; 13 | -------------------------------------------------------------------------------- /src/main/helpers/app.ts: -------------------------------------------------------------------------------- 1 | import electron from "electron"; 2 | 3 | let app: Electron.App; 4 | if (process.type) { 5 | app = 6 | electron.app || 7 | (() => { 8 | throw new Error("fail in app.ts"); 9 | })(); 10 | } 11 | 12 | export function getAppPath(): string { 13 | if (!app) { 14 | return ``; 15 | } 16 | return app.getAppPath(); 17 | } 18 | 19 | export function getVersion(): string { 20 | if (!app) { 21 | return ``; 22 | } 23 | return app.getVersion(); 24 | } 25 | -------------------------------------------------------------------------------- /src/main/helpers/browser-window.ts: -------------------------------------------------------------------------------- 1 | import electron from "electron"; 2 | 3 | const fakeWindow = { 4 | setMenu(menu: Electron.Menu) {}, 5 | } as Electron.BrowserWindow; 6 | 7 | export const BrowserWindow = { 8 | fromId(id: number): electron.BrowserWindow { 9 | if (!process.type) { 10 | return fakeWindow; 11 | } 12 | return electron.BrowserWindow.fromId(id); 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/main/helpers/menu.ts: -------------------------------------------------------------------------------- 1 | import electron from "electron"; 2 | 3 | export const Menu = { 4 | buildFromTemplate( 5 | template: Electron.MenuItemConstructorOptions[] 6 | ): Electron.Menu { 7 | if (!process.type) { 8 | return null; 9 | } 10 | validateMenuTemplate(template, "root"); 11 | return electron.Menu.buildFromTemplate(template); 12 | }, 13 | 14 | setApplicationMenu(menu: Electron.Menu) { 15 | if (!process.type) { 16 | return; 17 | } 18 | electron.Menu.setApplicationMenu(menu); 19 | }, 20 | }; 21 | 22 | function validateMenuTemplate(template: any[], path: string) { 23 | for (const item of template) { 24 | if (!item.label && !item.role && !item.type) { 25 | console.warn( 26 | `in path ${path}, menu template item has none of (label, role, type): ${JSON.stringify( 27 | item, 28 | null, 29 | 2 30 | )}` 31 | ); 32 | } 33 | if (item.submenu) { 34 | validateMenuTemplate(item.submenu, `${path}/${item.label || item.role}`); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import env from "main/env"; 2 | 3 | env.setNodeEnv(); 4 | 5 | if (env.integrationTests) { 6 | require("main/boot/test-paths").setup(); 7 | } 8 | 9 | require("main/crash-reporter").mount(); 10 | 11 | if (process.env.NODE_ENV !== "production") { 12 | Error.stackTraceLimit = 2000; 13 | 14 | require("bluebird").config({ 15 | longStackTraces: true, 16 | }); 17 | 18 | require("clarify"); 19 | } 20 | 21 | require("main/main").main(); 22 | -------------------------------------------------------------------------------- /src/main/inject/inject-captcha.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from "electron"; 2 | import { emitSyncIpcEvent } from "common/ipc"; 3 | import "@goosewobbler/electron-redux/preload"; 4 | 5 | contextBridge.exposeInMainWorld("onCaptcha", function (response: string) { 6 | emitSyncIpcEvent("onCaptchaResponse", response); 7 | }); 8 | -------------------------------------------------------------------------------- /src/main/logger/console-sink.ts: -------------------------------------------------------------------------------- 1 | import { LogEntry, levels, LogSink } from "common/logger"; 2 | const termColor = require("term-color"); 3 | 4 | const levelColors = { 5 | default: "white", 6 | 60: "bgRed", 7 | 50: "red", 8 | 40: "yellow", 9 | 30: "green", 10 | 20: "blue", 11 | 10: "grey", 12 | } as { [key: number]: string; default: string }; 13 | 14 | function asISODate(time: any) { 15 | return new Date(time).toISOString(); 16 | } 17 | 18 | function asColoredLevel(entry: LogEntry) { 19 | const formatter = termColor[levelColors[entry.level]]; 20 | const str = levels[entry.level]; 21 | if (formatter) { 22 | return formatter(str); 23 | } 24 | return str; 25 | } 26 | 27 | export const consoleSink: LogSink = { 28 | write(entry: LogEntry) { 29 | let line = 30 | asISODate(entry.time).split(/T|Z/)[1] + " " + asColoredLevel(entry); 31 | line += " "; 32 | if (entry.name) { 33 | line += "(" + entry.name + ") "; 34 | } 35 | if (entry.msg) { 36 | line += termColor.cyan(entry.msg); 37 | } 38 | line += "\n"; 39 | process.stdout.write(line); 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/main/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { Logger, multiSink, streamSink, LogSink } from "common/logger"; 2 | import { mainLogPath } from "main/util/paths"; 3 | import stream from "logrotate-stream"; 4 | import { consoleSink } from "main/logger/console-sink"; 5 | import path from "path"; 6 | import env from "main/env"; 7 | import { mkdirSync } from "fs"; 8 | 9 | export function getLogStream(): NodeJS.WritableStream { 10 | const logPath = mainLogPath(); 11 | try { 12 | mkdirSync(path.dirname(logPath), { recursive: true }); 13 | } catch (err) { 14 | if ((err as any).code === "EEXIST") { 15 | // good 16 | } else { 17 | console.log(`Could not create file sink: ${err.stack || err.message}`); 18 | } 19 | } 20 | 21 | return stream({ 22 | file: logPath, 23 | size: "2M", 24 | keep: 5, 25 | }); 26 | } 27 | 28 | function buildMainLogger(): Logger { 29 | let fileSink = streamSink(getLogStream()); 30 | 31 | let sink: LogSink; 32 | if (env.integrationTests) { 33 | sink = fileSink; 34 | } else { 35 | sink = multiSink(fileSink, consoleSink); 36 | } 37 | 38 | return new Logger(sink); 39 | } 40 | 41 | export const mainLogger = buildMainLogger(); 42 | -------------------------------------------------------------------------------- /src/main/modals.ts: -------------------------------------------------------------------------------- 1 | import { prepModals } from "common/modals"; 2 | import uuid from "common/util/uuid"; 3 | import rng from "main/util/rng"; 4 | 5 | const modals = prepModals(() => uuid(rng)); 6 | export default modals; 7 | -------------------------------------------------------------------------------- /src/main/net/index.ts: -------------------------------------------------------------------------------- 1 | export * from "main/net/request"; 2 | export * from "main/net/download"; 3 | -------------------------------------------------------------------------------- /src/main/net/request/index.ts: -------------------------------------------------------------------------------- 1 | import { RequestFunc } from "common/types"; 2 | 3 | let request: RequestFunc; 4 | 5 | if (process.type !== "browser") { 6 | throw new Error(`net/request cannot be loaded from ${process.type} process`); 7 | } 8 | 9 | request = require("./metal-request").request; 10 | export { request }; 11 | -------------------------------------------------------------------------------- /src/main/os/exit.ts: -------------------------------------------------------------------------------- 1 | import env from "main/env"; 2 | 3 | export function exit(exitCode: number) { 4 | if (env.integrationTests) { 5 | console.log(`this is the magic exit code: ${exitCode}`); 6 | } else { 7 | const electron = require("electron"); 8 | const app = electron.app; 9 | app.exit(exitCode); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/os/explorer.ts: -------------------------------------------------------------------------------- 1 | import { shell } from "electron"; 2 | 3 | export function open(folder: string) { 4 | if (process.platform === "darwin") { 5 | // openItem will open the finder but it will appear *under* the app 6 | // which is a bit silly, so we just reveal it instead. 7 | shell.showItemInFolder(folder); 8 | } else { 9 | shell.openPath(folder); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/os/ifs.ts: -------------------------------------------------------------------------------- 1 | // asar-aware filesystem module 2 | import fs from "fs"; 3 | 4 | /** reads an entire file as an UTF-8 string */ 5 | export async function readFile(file: string): Promise { 6 | return await new Promise((resolve, reject) => { 7 | fs.readFile(file, { encoding: "utf8" }, (err, res) => { 8 | if (err) { 9 | return reject(err); 10 | } 11 | resolve(res); 12 | }); 13 | }); 14 | } 15 | 16 | /** returns true if a file can be read (actually reads it to test) */ 17 | export async function exists(file: string): Promise { 18 | try { 19 | // Note: we can't use fs.access via ASAR, it always returns false 20 | await readFile(file); 21 | } catch (err) { 22 | return false; 23 | } 24 | return true; 25 | } 26 | 27 | import * as sf from "main/os/sf"; 28 | export const writeFile = sf.writeFile; 29 | -------------------------------------------------------------------------------- /src/main/reactors/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | 3 | import { clipboard } from "electron"; 4 | 5 | import { actions } from "common/actions"; 6 | 7 | export default function (watcher: Watcher) { 8 | watcher.on(actions.copyToClipboard, async (store, action) => { 9 | const text: string = action.payload.text; 10 | clipboard.writeText(text); 11 | store.dispatch( 12 | actions.statusMessage({ 13 | message: ["status.copied_to_clipboard"], 14 | }) 15 | ); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/reactors/delay.ts: -------------------------------------------------------------------------------- 1 | /** returns a promise that resolves after 'ms' milliseconds */ 2 | export function delay(ms: number) { 3 | return new Promise((resolve, reject) => setTimeout(resolve, ms)); 4 | } 5 | -------------------------------------------------------------------------------- /src/main/reactors/dialogs/change-user.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | import { actions } from "common/actions"; 3 | import modals from "main/modals"; 4 | 5 | export default function (watcher: Watcher) { 6 | watcher.on(actions.changeUser, async (store, action) => { 7 | store.dispatch( 8 | actions.openModal( 9 | modals.naked.make({ 10 | wind: "root", 11 | title: ["prompt.logout_title"], 12 | message: ["prompt.logout_confirm"], 13 | detail: ["prompt.logout_detail"], 14 | buttons: [ 15 | { 16 | id: "modal-logout", 17 | label: ["prompt.logout_action"], 18 | action: actions.requestLogout({}), 19 | icon: "exit", 20 | }, 21 | "cancel", 22 | ], 23 | widgetParams: null, 24 | }) 25 | ) 26 | ); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/main/reactors/dialogs/clear-browsing-data.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | 3 | import { actions } from "common/actions"; 4 | import { promisedModal } from "main/reactors/modals"; 5 | import modals from "main/modals"; 6 | 7 | export default function (watcher: Watcher) { 8 | watcher.on(actions.clearBrowsingDataRequest, async (store, action) => { 9 | const { wind } = action.payload; 10 | const response = await promisedModal( 11 | store, 12 | modals.clearBrowsingData.make({ 13 | wind, 14 | title: ["preferences.advanced.clear_browsing_data"], 15 | message: "", 16 | buttons: [ 17 | { 18 | label: ["prompt.clear_browsing_data.clear"], 19 | id: "modal-clear-data", 20 | action: "widgetResponse", 21 | }, 22 | "cancel", 23 | ], 24 | widgetParams: {}, 25 | }) 26 | ); 27 | 28 | if (!response) { 29 | // modal was closed 30 | return; 31 | } 32 | 33 | store.dispatch( 34 | actions.clearBrowsingData({ 35 | cache: response.cache, 36 | cookies: response.cookies, 37 | }) 38 | ); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/main/reactors/dialogs/force-close-game-request.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | import { actions } from "common/actions"; 3 | import modals from "main/modals"; 4 | 5 | export default function (watcher: Watcher) { 6 | watcher.on(actions.forceCloseGameRequest, async (store, action) => { 7 | const { game } = action.payload; 8 | 9 | store.dispatch( 10 | actions.openModal( 11 | modals.naked.make({ 12 | wind: "root", 13 | title: ["prompt.force_close_game.title"], 14 | message: ["prompt.force_close_game.message", { title: game.title }], 15 | buttons: [ 16 | { 17 | label: ["prompt.action.force_close"], 18 | id: "modal-force-close", 19 | action: actions.forceCloseGame({ gameId: game.id }), 20 | icon: "stop", 21 | }, 22 | "nevermind", 23 | ], 24 | widgetParams: null, 25 | }) 26 | ) 27 | ); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/main/reactors/dialogs/index.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | 3 | import changeUser from "main/reactors/dialogs/change-user"; 4 | import requestCaveUninstall from "main/reactors/dialogs/request-cave-uninstall"; 5 | import manageGame from "main/reactors/dialogs/manage-game"; 6 | import manageCave from "main/reactors/dialogs/manage-cave"; 7 | import forceCloseGameRequest from "main/reactors/dialogs/force-close-game-request"; 8 | import showGameUpdate from "main/reactors/dialogs/show-game-update"; 9 | import clearBrowsingData from "main/reactors/dialogs/clear-browsing-data"; 10 | import scanInstallLocations from "main/reactors/dialogs/scan-install-locations"; 11 | 12 | export default function (watcher: Watcher) { 13 | changeUser(watcher); 14 | manageGame(watcher); 15 | manageCave(watcher); 16 | requestCaveUninstall(watcher); 17 | forceCloseGameRequest(watcher); 18 | showGameUpdate(watcher); 19 | clearBrowsingData(watcher); 20 | scanInstallLocations(watcher); 21 | } 22 | -------------------------------------------------------------------------------- /src/main/reactors/dialogs/manage-cave.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import * as messages from "common/butlerd/messages"; 3 | import { formatUploadTitle } from "common/format/upload"; 4 | import { Watcher } from "common/util/watcher"; 5 | import modals from "main/modals"; 6 | import { mcall } from "main/butlerd/mcall"; 7 | 8 | export default function (watcher: Watcher) { 9 | watcher.on(actions.manageCave, async (store, action) => { 10 | const { caveId } = action.payload; 11 | 12 | const { cave } = await mcall(messages.FetchCave, { 13 | caveId, 14 | }); 15 | 16 | const widgetParams = { 17 | cave, 18 | }; 19 | 20 | const { game, upload } = cave; 21 | 22 | const openModal = actions.openModal( 23 | modals.manageCave.make({ 24 | wind: "root", 25 | title: `${game.title} - ${formatUploadTitle(upload)}`, 26 | message: "", 27 | widgetParams, 28 | }) 29 | ); 30 | store.dispatch(openModal); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/main/reactors/dialogs/request-cave-uninstall.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import * as messages from "common/butlerd/messages"; 3 | import { Watcher } from "common/util/watcher"; 4 | import { mcall } from "main/butlerd/mcall"; 5 | import modals from "main/modals"; 6 | 7 | export default function (watcher: Watcher) { 8 | watcher.on(actions.requestCaveUninstall, async (store, action) => { 9 | const { caveId } = action.payload; 10 | 11 | const { cave } = await mcall(messages.FetchCave, { caveId }); 12 | const { game } = cave; 13 | 14 | // FIXME: i18n - plus, that's generally bad 15 | const title = game ? game.title : "this"; 16 | 17 | store.dispatch( 18 | actions.openModal( 19 | modals.naked.make({ 20 | wind: "root", 21 | title: "", 22 | message: ["prompt.uninstall.message", { title }], 23 | buttons: [ 24 | { 25 | label: ["prompt.uninstall.reinstall"], 26 | id: "modal-reinstall", 27 | action: actions.queueCaveReinstall({ caveId }), 28 | icon: "repeat", 29 | }, 30 | { 31 | label: ["prompt.uninstall.uninstall"], 32 | id: "modal-uninstall", 33 | action: actions.queueCaveUninstall({ caveId }), 34 | icon: "uninstall", 35 | }, 36 | "cancel", 37 | ], 38 | widgetParams: null, 39 | }) 40 | ) 41 | ); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/main/reactors/dialogs/scan-install-locations.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import { Watcher } from "common/util/watcher"; 3 | 4 | export default function (watcher: Watcher) { 5 | watcher.on(actions.scanInstallLocations, async (store, action) => { 6 | store.dispatch( 7 | actions.openWind({ 8 | initialURL: "itch://scan-install-locations", 9 | role: "secondary", 10 | }) 11 | ); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/reactors/downloads/getters.ts: -------------------------------------------------------------------------------- 1 | import { DownloadsState } from "common/types"; 2 | 3 | import { first, filter, sortBy } from "underscore"; 4 | import { memoize } from "common/util/lru-memoize"; 5 | import { Download } from "common/butlerd/messages"; 6 | 7 | export const getActiveDownload = memoize(1, function ( 8 | downloads: DownloadsState 9 | ): Download { 10 | return first(getPendingDownloads(downloads)); 11 | }); 12 | 13 | export const getPendingDownloads = memoize(1, function ( 14 | downloads: DownloadsState 15 | ): Download[] { 16 | const pending = filter(downloads.items, (i) => !i.finishedAt); 17 | return sortBy(pending, "position"); 18 | }); 19 | 20 | export const getFinishedDownloads = memoize(1, function ( 21 | downloads: DownloadsState 22 | ): Download[] { 23 | const pending = filter(downloads.items, (i) => !!i.finishedAt); 24 | return sortBy(pending, "finishedAt").reverse(); 25 | }); 26 | 27 | export function getPendingForGame( 28 | downloads: DownloadsState, 29 | gameId: number 30 | ): Download[] { 31 | return filter( 32 | getPendingDownloads(downloads), 33 | (i) => i.game && +i.game.id === +gameId 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/main/reactors/downloads/index.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | 3 | import showDownloadError from "main/reactors/downloads/show-download-error"; 4 | import downloadEnded from "main/reactors/downloads/download-ended"; 5 | import driver from "main/reactors/downloads/driver"; 6 | import operations from "main/reactors/downloads/operations"; 7 | 8 | export default function (watcher: Watcher) { 9 | showDownloadError(watcher); 10 | downloadEnded(watcher); 11 | driver(watcher); 12 | operations(watcher); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/reactors/downloads/operations.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import * as messages from "common/butlerd/messages"; 3 | import { Watcher } from "common/util/watcher"; 4 | import { mcall } from "main/butlerd/mcall"; 5 | 6 | export default function (watcher: Watcher) { 7 | watcher.on(actions.downloadQueued, async (store, action) => { 8 | store.dispatch(actions.refreshDownloads({})); 9 | }); 10 | 11 | watcher.on(actions.prioritizeDownload, async (store, action) => { 12 | const { id } = action.payload; 13 | await mcall(messages.DownloadsPrioritize, { downloadId: id }); 14 | store.dispatch(actions.refreshDownloads({})); 15 | }); 16 | 17 | watcher.on(actions.discardDownload, async (store, action) => { 18 | const { id } = action.payload; 19 | await mcall(messages.DownloadsDiscard, { downloadId: id }); 20 | store.dispatch(actions.refreshDownloads({})); 21 | }); 22 | 23 | watcher.on(actions.retryDownload, async (store, action) => { 24 | const { id } = action.payload; 25 | await mcall(messages.DownloadsRetry, { downloadId: id }); 26 | store.dispatch(actions.refreshDownloads({})); 27 | }); 28 | 29 | watcher.on(actions.clearFinishedDownloads, async (store, action) => { 30 | await mcall(messages.DownloadsClearFinished, {}); 31 | store.dispatch(actions.refreshDownloads({})); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/main/reactors/downloads/perform-uninstall.ts: -------------------------------------------------------------------------------- 1 | import { hookLogging } from "common/butlerd/utils"; 2 | import * as messages from "common/butlerd/messages"; 3 | import { Logger } from "common/logger"; 4 | import { Store } from "common/types"; 5 | import { mcall } from "main/butlerd/mcall"; 6 | 7 | export async function performUninstall( 8 | store: Store, 9 | parentLogger: Logger, 10 | caveId: string 11 | ) { 12 | const logger = parentLogger.child(__filename); 13 | 14 | await mcall(messages.UninstallPerform, { caveId }, (convo) => { 15 | hookLogging(convo, logger); 16 | 17 | convo.onNotification(messages.TaskStarted, async ({ type, reason }) => { 18 | logger.info(`Task ${type} started (for ${reason})`); 19 | }); 20 | 21 | convo.onNotification(messages.TaskSucceeded, async ({ type }) => { 22 | logger.info(`Task ${type} succeeded`); 23 | }); 24 | }); 25 | logger.info(`Uninstall successful`); 26 | } 27 | -------------------------------------------------------------------------------- /src/main/reactors/downloads/show-download-error.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import { getDownloadError } from "common/format/errors"; 3 | import { Watcher } from "common/util/watcher"; 4 | import { mainLogger } from "main/logger"; 5 | import { join } from "path"; 6 | import * as sf from "main/os/sf"; 7 | import { showInstallErrorModal } from "main/reactors/tasks/show-install-error-modal"; 8 | 9 | const logger = mainLogger.child(__filename); 10 | 11 | export default function (watcher: Watcher) { 12 | watcher.on(actions.showDownloadError, async (store, action) => { 13 | const { id } = action.payload; 14 | 15 | const { downloads } = store.getState(); 16 | const item = downloads.items[id]; 17 | if (!item) { 18 | logger.warn( 19 | `can't show download error for item we don't know about! (${id})` 20 | ); 21 | return; 22 | } 23 | 24 | const operateLogPath = join(item.stagingFolder, "operate-log.json"); 25 | let log = ""; 26 | try { 27 | log = await sf.readFile(operateLogPath, { encoding: "utf8" }); 28 | } catch (e) { 29 | logger.warn(`could not read log: ${e.stack}`); 30 | } 31 | 32 | await showInstallErrorModal({ 33 | store, 34 | e: getDownloadError(item), 35 | log, 36 | game: item.game, 37 | retryAction: () => actions.retryDownload({ id }), 38 | stopAction: () => actions.discardDownload({ id }), 39 | }); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/main/reactors/i18n.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | import { createSelector } from "reselect"; 3 | 4 | import { RootState } from "common/types"; 5 | import { actions } from "common/actions"; 6 | 7 | import { mainLogger } from "main/logger"; 8 | 9 | const fallbackLang = "en"; 10 | const logger = mainLogger.child(__filename); 11 | 12 | export default function (watcher: Watcher) { 13 | watcher.onStateChange({ 14 | makeSelector: (store, schedule) => 15 | createSelector( 16 | (rs: RootState) => rs.system.sniffedLanguage, 17 | (rs: RootState) => rs.preferences.lang, 18 | (sniffedLang, preferenceLang) => { 19 | const lang = preferenceLang || sniffedLang || fallbackLang; 20 | logger.info( 21 | `Language settings: preference ${preferenceLang}, sniffed ${sniffedLang}, fallback ${fallbackLang}` 22 | ); 23 | schedule.dispatch(actions.languageChanged({ lang })); 24 | } 25 | ), 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/main/reactors/modals-persistent-state.ts: -------------------------------------------------------------------------------- 1 | // this is a separate module so it doesn't get reloaded when doing HMR 2 | // otherwise cancelling dialogs after code has reloaded doesn't work properly. 3 | 4 | interface ModalResolveMap { 5 | [modalId: string]: (response: any) => void; 6 | } 7 | const modalResolves: ModalResolveMap = {}; 8 | export default modalResolves; 9 | -------------------------------------------------------------------------------- /src/main/reactors/open-app-devtools.ts: -------------------------------------------------------------------------------- 1 | import "electron"; 2 | 3 | export function openAppDevTools(bw: Electron.BrowserWindow) { 4 | if (bw) { 5 | const wc = bw.webContents; 6 | if (wc && !wc.isDestroyed()) { 7 | const dwc = wc.devToolsWebContents; 8 | if (dwc) { 9 | wc.devToolsWebContents.focus(); 10 | } else { 11 | wc.openDevTools({ mode: "detach" }); 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/reactors/preboot/load-preferences.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import { camelifyObject } from "common/format/camelify"; 3 | import { initialState } from "common/reducers/preferences"; 4 | import { PreferencesState, Store } from "common/types"; 5 | import { preferencesPath } from "main/util/paths"; 6 | import fs from "fs"; 7 | import { mainLogger } from "main/logger"; 8 | 9 | const logger = mainLogger.child(__filename); 10 | 11 | async function loadPreferences(store: Store) { 12 | const prefs = loadPreferencesSync(); 13 | store.dispatch(actions.updatePreferences(prefs)); 14 | store.dispatch(actions.preferencesLoaded(prefs)); 15 | } 16 | 17 | export default loadPreferences; 18 | 19 | export function loadPreferencesSync(): PreferencesState { 20 | let prefs = initialState; 21 | 22 | try { 23 | const contents = fs.readFileSync(preferencesPath(), { 24 | encoding: "utf8", 25 | }); 26 | prefs = mergePreferences(contents); 27 | logger.debug(`imported preferences: ${JSON.stringify(prefs)}`); 28 | } catch (e) { 29 | if (e.code === "ENOENT") { 30 | // ignore 31 | } else { 32 | logger.warn(`while importing preferences: ${e.stack}`); 33 | } 34 | } 35 | 36 | return prefs; 37 | } 38 | 39 | function mergePreferences(contents: string): PreferencesState { 40 | return { 41 | ...initialState, 42 | ...camelifyObject(JSON.parse(contents)), 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/main/reactors/profile.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | 3 | import { actions } from "common/actions"; 4 | import { getActiveDownload } from "main/reactors/downloads/getters"; 5 | 6 | export default function (watcher: Watcher) { 7 | watcher.on(actions.loginSucceeded, async (store, action) => { 8 | // resume downloads 9 | store.dispatch(actions.setDownloadsPaused({ paused: false })); 10 | 11 | // and open downloads tab if we have some pending 12 | const { downloads } = store.getState(); 13 | if (getActiveDownload(downloads)) { 14 | store.dispatch( 15 | actions.navigate({ 16 | wind: "root", 17 | url: "itch://downloads", 18 | background: true, 19 | }) 20 | ); 21 | } 22 | }); 23 | 24 | watcher.on(actions.loggedOut, async (store, action) => { 25 | store.dispatch(actions.setDownloadsPaused({ paused: true })); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/main/reactors/proxy.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | import { actions } from "common/actions"; 3 | 4 | import { ProxySettings } from "common/types"; 5 | import { partitionForUser } from "common/util/partition-for-user"; 6 | import { session, Session } from "electron"; 7 | 8 | export default function (watcher: Watcher) { 9 | watcher.on(actions.loginSucceeded, async (store, action) => { 10 | const userId = action.payload.profile.user.id; 11 | 12 | const partition = partitionForUser(String(userId)); 13 | const ourSession = session.fromPartition(partition, { cache: true }); 14 | await applyProxySettings(ourSession, store.getState().system); 15 | }); 16 | } 17 | 18 | export async function applyProxySettings( 19 | session: Session, 20 | system: ProxySettings 21 | ) { 22 | if (process.env.ITCH_EMULATE_OFFLINE === "1") { 23 | session.enableNetworkEmulation({ 24 | offline: true, 25 | }); 26 | } 27 | 28 | if (system.proxySource === "os") { 29 | // this means they've been detected from OS, no need to set them manually 30 | return; 31 | } 32 | 33 | const proxyRules = system.proxy; 34 | 35 | await session.setProxy({ 36 | pacScript: null, 37 | proxyRules, 38 | proxyBypassRules: null, 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/main/reactors/purchases.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | import * as url from "main/util/url"; 3 | 4 | import { actions } from "common/actions"; 5 | 6 | function buildLoginAndReturnUrl(returnTo: string): string { 7 | const parsed = url.parse(returnTo); 8 | const hostname = url.subdomainToDomain(parsed.hostname); 9 | 10 | let urlOpts: Partial = { 11 | hostname, 12 | pathname: "/login", 13 | query: { return_to: returnTo }, 14 | }; 15 | 16 | if (hostname === "itch.io") { 17 | urlOpts.protocol = "https"; 18 | } else { 19 | urlOpts.port = parsed.port; 20 | urlOpts.protocol = parsed.protocol; 21 | } 22 | 23 | return url.format(urlOpts); 24 | } 25 | 26 | export default function (watcher: Watcher) { 27 | watcher.on(actions.initiatePurchase, async (store, action) => { 28 | const { game } = action.payload; 29 | const purchaseUrl = game.url + "/purchase"; 30 | const loginPurchaseUrl = buildLoginAndReturnUrl(purchaseUrl); 31 | 32 | store.dispatch(actions.navigate({ wind: "root", url: loginPurchaseUrl })); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/main/reactors/tasks/abort-game.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | import { actions } from "common/actions"; 3 | 4 | import { sortBy } from "underscore"; 5 | 6 | export default function (watcher: Watcher) { 7 | watcher.on(actions.forceCloseLastGame, async (store, action) => { 8 | const tasks = sortBy(store.getState().tasks.tasks, "startedAt"); 9 | 10 | if (tasks.length > 0) { 11 | const task = tasks[0]; 12 | store.dispatch(actions.abortTask({ id: task.id })); 13 | } 14 | }); 15 | 16 | watcher.on(actions.forceCloseGame, async (store, action) => { 17 | const { gameId } = action.payload; 18 | 19 | const { tasks } = store.getState().tasks; 20 | 21 | for (const taskId of Object.keys(tasks)) { 22 | const task = tasks[taskId]; 23 | if (task.gameId === gameId && task.name === "launch") { 24 | store.dispatch(actions.abortTask({ id: task.id })); 25 | } 26 | } 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/main/reactors/tasks/abort-task.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import { Watcher } from "common/util/watcher"; 3 | import { mainLogger } from "main/logger"; 4 | import { getCurrentTasks } from "main/reactors/tasks/as-task-persistent-state"; 5 | 6 | const logger = mainLogger.child(__filename); 7 | 8 | export default function (watcher: Watcher) { 9 | watcher.on(actions.abortTask, async (store, action) => { 10 | const { id } = action.payload; 11 | const ctx = getCurrentTasks()[id]; 12 | if (ctx) { 13 | try { 14 | await ctx.tryAbort(); 15 | } catch (e) { 16 | logger.warn(`Could not cancel task ${id}: ${e.stack}`); 17 | } 18 | } 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/main/reactors/tasks/as-task-persistent-state.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "main/context"; 2 | 3 | interface TaskMap { 4 | [id: string]: Context; 5 | } 6 | 7 | let currentTasks = {} as TaskMap; 8 | 9 | export const getCurrentTasks = () => currentTasks; 10 | -------------------------------------------------------------------------------- /src/main/reactors/tasks/explore-cave.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import * as messages from "common/butlerd/messages"; 3 | import { Watcher } from "common/util/watcher"; 4 | import fs from "fs"; 5 | import { dirname } from "path"; 6 | import * as explorer from "main/os/explorer"; 7 | import { mcall } from "main/butlerd/mcall"; 8 | 9 | export default function (watcher: Watcher) { 10 | watcher.on(actions.exploreCave, async (store, action) => { 11 | const { caveId } = action.payload; 12 | 13 | const { cave } = await mcall(messages.FetchCave, { caveId }); 14 | const installFolder = cave.installInfo.installFolder; 15 | try { 16 | fs.accessSync(installFolder); 17 | explorer.open(installFolder); 18 | } catch (e) { 19 | explorer.open(dirname(installFolder)); 20 | } 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/main/reactors/tasks/getters.ts: -------------------------------------------------------------------------------- 1 | import { Task, TasksState } from "common/types"; 2 | import { first, values } from "underscore"; 3 | import { memoize } from "common/util/lru-memoize"; 4 | 5 | export const getActiveTask = memoize(1, function (tasks: TasksState): Task { 6 | return first(getRunningTasks(tasks)); 7 | }); 8 | 9 | export const getRunningTasks = memoize(1, function (tasks: TasksState): Task[] { 10 | return values(tasks.tasks); 11 | }); 12 | -------------------------------------------------------------------------------- /src/main/reactors/tasks/index.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | 3 | import abortTask from "main/reactors/tasks/abort-task"; 4 | 5 | import queueGame from "main/reactors/tasks/queue-game"; 6 | 7 | import queueCaveReinstall from "main/reactors/tasks/queue-cave-reinstall"; 8 | import queueCaveUninstall from "main/reactors/tasks/queue-cave-uninstall"; 9 | import exploreCave from "main/reactors/tasks/explore-cave"; 10 | import abortGame from "main/reactors/tasks/abort-game"; 11 | import switchVersionCave from "main/reactors/tasks/switch-version-cave"; 12 | import viewCaveDetails from "main/reactors/tasks/view-cave-details"; 13 | 14 | export default function (watcher: Watcher) { 15 | abortTask(watcher); 16 | 17 | queueGame(watcher); 18 | queueCaveReinstall(watcher); 19 | queueCaveUninstall(watcher); 20 | exploreCave(watcher); 21 | abortGame(watcher); 22 | switchVersionCave(watcher); 23 | viewCaveDetails(watcher); 24 | } 25 | -------------------------------------------------------------------------------- /src/main/reactors/tasks/queue-cave-reinstall.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import * as messages from "common/butlerd/messages"; 3 | import { DownloadReason } from "common/butlerd/messages"; 4 | import { Watcher } from "common/util/watcher"; 5 | import { mcall } from "main/butlerd/mcall"; 6 | 7 | export default function (watcher: Watcher) { 8 | watcher.on(actions.queueCaveReinstall, async (store, action) => { 9 | const { caveId } = action.payload; 10 | 11 | await mcall(messages.InstallQueue, { 12 | caveId, 13 | reason: DownloadReason.Reinstall, 14 | queueDownload: true, 15 | fastQueue: true, 16 | }); 17 | store.dispatch(actions.downloadQueued({})); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/reactors/tasks/view-cave-details.ts: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import * as messages from "common/butlerd/messages"; 3 | import { Watcher } from "common/util/watcher"; 4 | import { mcall } from "main/butlerd/mcall"; 5 | import modals from "main/modals"; 6 | 7 | export default function (watcher: Watcher) { 8 | watcher.on(actions.viewCaveDetails, async (store, action) => { 9 | const { caveId } = action.payload; 10 | 11 | const { cave } = await mcall(messages.FetchCave, { caveId }); 12 | 13 | store.dispatch( 14 | actions.openModal( 15 | modals.exploreJson.make({ 16 | wind: "root", 17 | title: `Cave details for ${cave.game ? cave.game.title : "?"}`, 18 | message: "Local cave data:", 19 | widgetParams: { 20 | data: cave, 21 | }, 22 | buttons: [ 23 | { 24 | label: ["prompt.action.ok"], 25 | }, 26 | ], 27 | }) 28 | ) 29 | ); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/main/reactors/triggers.ts: -------------------------------------------------------------------------------- 1 | import { Watcher } from "common/util/watcher"; 2 | 3 | import { actions } from "common/actions"; 4 | 5 | export default function (watcher: Watcher) { 6 | watcher.on(actions.commandBack, async (store, action) => { 7 | const { wind } = action.payload; 8 | const modals = store.getState().winds[wind].modals; 9 | const [modal] = modals; 10 | 11 | if (modal) { 12 | store.dispatch(actions.closeModal({ wind })); 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/reactors/web-contents/parse-well-known-url.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "url"; 2 | import { mainLogger } from "main/logger"; 3 | const COLLECTION_URL_RE = /^\/c\/([0-9]+)/; 4 | const DOWNLOAD_URL_RE = /^.*\/download\/[a-zA-Z0-9]*$/; 5 | 6 | const logger = mainLogger.child(__filename); 7 | 8 | export interface WellKnownUrlResult { 9 | resource: string; 10 | url: string; 11 | } 12 | 13 | export function parseWellKnownUrl(url: string): WellKnownUrlResult { 14 | try { 15 | const u = parse(url); 16 | if (u.hostname === "itch.io") { 17 | const collMatches = COLLECTION_URL_RE.exec(u.pathname); 18 | if (collMatches) { 19 | return { 20 | resource: `collections/${collMatches[1]}`, 21 | url, 22 | }; 23 | } 24 | } else if (u.hostname.endsWith(".itch.io")) { 25 | const dlMatches = DOWNLOAD_URL_RE.exec(u.pathname); 26 | if (dlMatches) { 27 | let gameUrl = url.replace(/\/download.*$/, ""); 28 | return { 29 | resource: null, 30 | url: gameUrl, 31 | }; 32 | } 33 | } 34 | } catch (e) { 35 | logger.warn(`Could not parse url: ${url}`); 36 | } 37 | 38 | return null; 39 | } 40 | -------------------------------------------------------------------------------- /src/main/reactors/web-contents/web-contents-state.ts: -------------------------------------------------------------------------------- 1 | import { WebContents } from "electron"; 2 | 3 | const webContents: { 4 | [wind: string]: { 5 | [tab: string]: WebContents; 6 | }; 7 | } = {}; 8 | 9 | export function storeWebContents(wind: string, tab: string, wc: WebContents) { 10 | if (!(wind in webContents)) { 11 | webContents[wind] = {}; 12 | } 13 | webContents[wind][tab] = wc; 14 | } 15 | 16 | export function getWebContents(wind: string, tab: string): WebContents | null { 17 | if (!(wind in webContents)) { 18 | return null; 19 | } 20 | return webContents[wind][tab]; 21 | } 22 | 23 | export function forgetWebContents(wind: string, tab: string) { 24 | if (!(wind in webContents)) { 25 | return; 26 | } 27 | delete webContents[wind][tab]; 28 | } 29 | 30 | export function webContentsToTab(wc: WebContents): string { 31 | for (const wind of Object.keys(webContents)) { 32 | const tabs = webContents[wind]; 33 | for (const tab of Object.keys(tabs)) { 34 | const tbv = webContents[wind][tab]; 35 | if (tbv === wc) { 36 | return tab; 37 | } 38 | } 39 | } 40 | return null; 41 | } 42 | -------------------------------------------------------------------------------- /src/main/util/config.ts: -------------------------------------------------------------------------------- 1 | import ospath from "path"; 2 | import fs from "fs"; 3 | import { app } from "electron"; 4 | import { writeFile } from "main/os/sf"; 5 | 6 | let configFile = ospath.join(app.getPath("userData"), "config.json"); 7 | let data: any = {}; 8 | 9 | try { 10 | data = JSON.parse(fs.readFileSync(configFile, { encoding: "utf8" })); 11 | } catch (e) { 12 | // We don't want that to be fatal 13 | if (e.code === "ENOENT") { 14 | // that's ok 15 | } else { 16 | console.warn(`Could not read config: ${e}`); 17 | } 18 | } 19 | 20 | const self = { 21 | save: function () { 22 | const promise = writeFile(configFile, JSON.stringify(data), { 23 | encoding: "utf8", 24 | }); 25 | promise.catch((err) => { 26 | console.warn(`Could not save config: ${err}`); 27 | }); 28 | }, 29 | 30 | get: function (key: string): any { 31 | return data[key]; 32 | }, 33 | 34 | set: function (key: string, value: any) { 35 | data[key] = value; 36 | self.save(); 37 | }, 38 | 39 | clear: function (key: string) { 40 | delete data[key]; 41 | self.save(); 42 | }, 43 | }; 44 | 45 | export default self; 46 | -------------------------------------------------------------------------------- /src/main/util/rng.ts: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const rng = () => crypto.randomBytes(16); 3 | export default rng; 4 | -------------------------------------------------------------------------------- /src/main/util/url.ts: -------------------------------------------------------------------------------- 1 | /* node's standard url module */ 2 | import { parse } from "url"; 3 | import env from "main/env"; 4 | export * from "url"; 5 | 6 | /** user.example.org => example.org */ 7 | export function subdomainToDomain(subdomain: string): string { 8 | const parts = subdomain.split("."); 9 | while (parts.length > 2) { 10 | parts.shift(); 11 | } 12 | return parts.join("."); 13 | } 14 | 15 | const handledProtocols = [`${env.appName}io:`, `${env.appName}:`]; 16 | 17 | export function isItchioURL(s: string): boolean { 18 | try { 19 | let hasProperPrefix = false; 20 | for (const handledProtocol of handledProtocols) { 21 | if (s.startsWith(handledProtocol)) { 22 | hasProperPrefix = true; 23 | break; 24 | } 25 | } 26 | 27 | if (!hasProperPrefix) { 28 | return false; 29 | } 30 | 31 | const { protocol } = parse(s); 32 | for (const handledProtocol of handledProtocols) { 33 | if (protocol === handledProtocol) { 34 | return true; 35 | } 36 | } 37 | } catch (e) {} 38 | return false; 39 | } 40 | -------------------------------------------------------------------------------- /src/main/util/useragent.ts: -------------------------------------------------------------------------------- 1 | import * as electron from "electron"; 2 | import { NET_PARTITION_NAME } from "common/constants/net"; 3 | const app = 4 | electron.app || 5 | (() => { 6 | throw new Error("fail in user agent 1.ts"); 7 | })(); 8 | const session = 9 | electron.session || 10 | (() => { 11 | throw new Error("fail in user agent 2.ts"); 12 | })(); 13 | 14 | let _cachedUserAgent: string; 15 | export function userAgent() { 16 | if (!_cachedUserAgent) { 17 | const netSession = session.fromPartition(NET_PARTITION_NAME, { 18 | cache: false, 19 | }); 20 | _cachedUserAgent = `${netSession.getUserAgent()} itch/${app.getVersion()}`; 21 | } 22 | return _cachedUserAgent; 23 | } 24 | 25 | let _cachedButlerUserAgent: string; 26 | export function butlerUserAgent() { 27 | if (!_cachedButlerUserAgent) { 28 | _cachedButlerUserAgent = `itch/${app.getVersion()} (${process.platform})`; 29 | } 30 | return _cachedButlerUserAgent; 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/App/Layout/NonLocalIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import urls from "common/constants/urls"; 3 | 4 | import styled, * as styles from "renderer/styles"; 5 | 6 | const IndicatorDiv = styled.div` 7 | position: absolute; 8 | left: 50%; 9 | bottom: 8px; 10 | transform: translateX(-50%); 11 | background: rgba(0, 0, 0, 0.4); 12 | color: ${styles.colors.secondaryTextHover}; 13 | box-shadow: 0 0 2px ${styles.colors.baseBackground}; 14 | border: 1px solid ${styles.colors.secondaryText}; 15 | border-radius: 1px; 16 | font-size: 16px; 17 | padding: 6px; 18 | pointer-events: none; 19 | `; 20 | 21 | class NonLocalIndicator extends React.PureComponent<{}, {}> { 22 | render() { 23 | if (urls.itchio === urls.originalItchio) { 24 | return null; 25 | } 26 | 27 | return ( 28 | 29 | {urls.itchio} 30 | 31 | ); 32 | } 33 | } 34 | 35 | export default NonLocalIndicator; 36 | -------------------------------------------------------------------------------- /src/renderer/basics/Cover/GifMarker.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "renderer/styles"; 3 | 4 | const GifMarkerSpan = styled.span` 5 | position: absolute; 6 | top: 5px; 7 | left: 5px; 8 | background: #333333; 9 | color: rgba(253, 253, 253, 0.74); 10 | font-size: 12px; 11 | padding: 4px; 12 | border-radius: 2px; 13 | font-weight: bold; 14 | opacity: 0.8; 15 | z-index: 2; 16 | 17 | transition: all 0.2s; 18 | `; 19 | 20 | class GifMarker extends React.PureComponent { 21 | render() { 22 | const { label = "GIF" } = this.props; 23 | return {label}; 24 | } 25 | } 26 | 27 | interface Props { 28 | label?: string | JSX.Element | JSX.Element[]; 29 | } 30 | 31 | export default GifMarker; 32 | -------------------------------------------------------------------------------- /src/renderer/basics/Cover/SmartImage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "renderer/styles"; 3 | 4 | class Image extends React.PureComponent { 5 | render() { 6 | const { onLoadStart, onLoadEnd, ...restProps } = this.props; 7 | return ; 8 | } 9 | 10 | componentDidMount() { 11 | this.props.onLoadStart(); 12 | } 13 | 14 | componentDidUpdate(props: Props, state: any, snapshot: Props) { 15 | if (snapshot && snapshot.src !== props.src) { 16 | this.props.onLoadStart(); 17 | } 18 | } 19 | 20 | getSnapshotBeforeUpdate(prevProps: Props): Props { 21 | if (prevProps.src !== this.props.src) { 22 | return prevProps; 23 | } 24 | return null; 25 | } 26 | } 27 | 28 | interface Props { 29 | src?: string; 30 | className?: string; 31 | onLoadStart: () => void; 32 | onLoadEnd: () => void; 33 | onError: () => void; 34 | } 35 | 36 | export default styled(Image)` 37 | position: absolute; 38 | top: 0; 39 | left: 0; 40 | width: 100%; 41 | height: 100%; 42 | 43 | object-fit: cover; 44 | 45 | &.error { 46 | visibility: hidden; 47 | } 48 | `; 49 | -------------------------------------------------------------------------------- /src/renderer/basics/DownloadProgressSpan.tsx: -------------------------------------------------------------------------------- 1 | import { fileSize } from "common/format/filesize"; 2 | import React from "react"; 3 | import FormattedDuration from "renderer/basics/FormattedDuration"; 4 | import { T } from "renderer/t"; 5 | 6 | interface Props { 7 | bps?: number; 8 | eta?: number; 9 | downloadsPaused: boolean; 10 | onlyBPS?: boolean; 11 | onlyETA?: boolean; 12 | } 13 | 14 | export default function DownloadProgressSpan({ 15 | bps, 16 | eta, 17 | downloadsPaused, 18 | onlyBPS, 19 | onlyETA, 20 | }: Props): JSX.Element { 21 | if (downloadsPaused) { 22 | return <>{T(["grid.item.downloads_paused"])}; 23 | } 24 | 25 | if (onlyBPS) { 26 | return ( 27 | <> 28 | {fileSize(bps)} 29 | /s 30 | 31 | ); 32 | } 33 | 34 | if (onlyETA) { 35 | return ; 36 | } 37 | 38 | const hasBPS = bps > 0; 39 | const hasETA = eta > 0; 40 | return ( 41 | 42 | {hasBPS ? ( 43 | <> 44 | {fileSize(bps)} 45 | {"/s"} 46 | 47 | ) : null} 48 | {hasBPS && hasETA ? " — " : null} 49 | {hasETA ? : null} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/renderer/basics/ErrorState.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "renderer/styles"; 3 | 4 | const ButlerErrorDiv = styled.div` 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | margin: 2em 0; 9 | 10 | .spacer { 11 | width: 8px; 12 | } 13 | `; 14 | 15 | const ErrorState = ({ error }: { error: Error }) => { 16 | if (!error) { 17 | return null; 18 | } 19 | 20 | return ( 21 | 22 | 23 |
24 | 25 | {String(error)} 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default ErrorState; 32 | -------------------------------------------------------------------------------- /src/renderer/basics/Filler.tsx: -------------------------------------------------------------------------------- 1 | import styled from "renderer/styles"; 2 | 3 | const Filler = styled.div` 4 | flex-grow: 100; 5 | `; 6 | 7 | export default Filler; 8 | -------------------------------------------------------------------------------- /src/renderer/basics/Floater.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | import styled, { keyframes } from "renderer/styles"; 4 | 5 | const floaterKeyframes = keyframes` 6 | 0% { 7 | transform: scale(0); 8 | opacity: .6; 9 | } 10 | 11 | 100% { 12 | transform: scale(1); 13 | opacity: 1; 14 | } 15 | `; 16 | 17 | const numDrops = 3; 18 | const duration = 0.5; 19 | const offset = duration / numDrops; 20 | 21 | const FloaterDiv = styled.div` 22 | width: 30px; 23 | height: 10px; 24 | 25 | display: flex; 26 | flex-flow: row; 27 | align-items: center; 28 | justify-content: center; 29 | 30 | div { 31 | width: 8px; 32 | height: 6px; 33 | margin: 0 2px; 34 | border-radius: 50%; 35 | background: white; 36 | animation: ${floaterKeyframes} ${duration}s infinite ease-in-out alternate; 37 | } 38 | 39 | &.tiny { 40 | width: 20px; 41 | height: 8px; 42 | 43 | div { 44 | width: 6px; 45 | height: 3px; 46 | } 47 | } 48 | 49 | div:nth-child(1) { 50 | animation-delay: -${offset * 2}s; 51 | } 52 | 53 | div:nth-child(2) { 54 | animation-delay: -${offset * 1}s; 55 | } 56 | `; 57 | 58 | export default function Floater(props: { tiny?: boolean }) { 59 | const { tiny } = props; 60 | return ( 61 | 62 |
63 |
64 |
65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/basics/FormattedDuration.tsx: -------------------------------------------------------------------------------- 1 | import { formatDurationAsMessage } from "common/format/datetime"; 2 | import React from "react"; 3 | import { FormattedMessage } from "react-intl"; 4 | 5 | interface Props { 6 | /** A duration, in seconds */ 7 | secs: number; 8 | } 9 | 10 | /** 11 | * Renders a human-friendly (and localized) duration 12 | */ 13 | export default ({ secs }: Props) => ( 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /src/renderer/basics/GameStatusGetter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import getGameStatus, { GameStatus } from "common/helpers/get-game-status"; 3 | import { Game } from "common/butlerd/messages"; 4 | import { hookWithProps } from "renderer/hocs/hook"; 5 | 6 | interface Props { 7 | game: Game; 8 | caveId?: string; 9 | render: (status: GameStatus) => JSX.Element; 10 | 11 | status: GameStatus; 12 | } 13 | 14 | class GameStatusGetter extends React.PureComponent { 15 | render() { 16 | const { status, render } = this.props; 17 | return render(status); 18 | } 19 | } 20 | 21 | export default hookWithProps(GameStatusGetter)((map) => ({ 22 | status: map((rs, props) => getGameStatus(rs, props.game, props.caveId)), 23 | }))(GameStatusGetter); 24 | -------------------------------------------------------------------------------- /src/renderer/basics/Icon.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { LocalizedString } from "common/types"; 3 | import React from "react"; 4 | 5 | /** 6 | * An icon from the icomoon font. 7 | * Peek in the static/fonts/icomoon/ folder to learn more. 8 | */ 9 | class Icon extends React.PureComponent { 10 | render() { 11 | const { icon, className, hint, ...restProps } = this.props; 12 | if (!icon) { 13 | return ; 14 | } 15 | 16 | const finalClassName = classNames(className, `icon icon-${icon}`); 17 | 18 | return ( 19 | 25 | ); 26 | } 27 | } 28 | 29 | interface Props { 30 | icon: string; 31 | hint?: LocalizedString; 32 | className?: string; 33 | onClick?: any; 34 | } 35 | 36 | export default Icon; 37 | -------------------------------------------------------------------------------- /src/renderer/basics/Link.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled, * as styles from "renderer/styles"; 3 | 4 | const LinkSpan = styled.span` 5 | ${styles.secondaryLink}; 6 | 7 | transition: color 0.4s; 8 | flex-shrink: 0.1; 9 | overflow-x: hidden; 10 | text-overflow: ellipsis; 11 | `; 12 | 13 | class Link extends React.PureComponent { 14 | render() { 15 | const { label, children, ...restProps } = this.props; 16 | 17 | return ( 18 | 19 | {label} 20 | {children} 21 | 22 | ); 23 | } 24 | } 25 | 26 | export default Link; 27 | 28 | class Props { 29 | onClick?: React.EventHandler>; 30 | onContextMenu?: React.EventHandler>; 31 | label?: JSX.Element | string; 32 | children?: string | JSX.Element | (string | JSX.Element)[]; 33 | className?: string; 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/basics/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import urls from "common/constants/urls"; 2 | import marked from "marked-extra"; 3 | import { emojify } from "node-emoji"; 4 | import React from "react"; 5 | 6 | class Markdown extends React.PureComponent { 7 | render() { 8 | return
; 9 | } 10 | 11 | renderHTML() { 12 | const { source } = this.props; 13 | 14 | const emojified = emojify(source); 15 | const autolinked = autolink(emojified); 16 | const sanitized = sanitize(autolinked); 17 | 18 | let html; 19 | try { 20 | html = marked(sanitized); 21 | } catch (e) { 22 | html = `Markdown error: ${e.error}`; 23 | } 24 | return { __html: html }; 25 | } 26 | } 27 | 28 | export default Markdown; 29 | 30 | interface Props { 31 | source: string; 32 | } 33 | 34 | const autolink = (src: string) => { 35 | return src.replace( 36 | /#([0-9]+)/g, 37 | (match, p1) => `[${match}](${urls.itchRepo}/issues/${p1})` 38 | ); 39 | }; 40 | 41 | const sanitize = (src: string) => { 42 | return src.replace(/\n##/g, "\n\n##"); 43 | }; 44 | -------------------------------------------------------------------------------- /src/renderer/basics/PlatformIcons/PlatformIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Icon from "renderer/basics/Icon"; 3 | import platformData, { PlatformHolder } from "common/constants/platform-data"; 4 | 5 | export default class PlatformIcon extends React.PureComponent { 6 | render() { 7 | const { target, field, before } = this.props; 8 | if (!target.platforms || !(target.platforms as any)[field]) { 9 | return null; 10 | } 11 | 12 | const data = platformData[field]; 13 | return ( 14 | <> 15 | {before} 16 | 17 | 18 | ); 19 | } 20 | } 21 | 22 | interface Props { 23 | target: PlatformHolder; 24 | field: keyof typeof platformData; 25 | before?: React.ReactNode; 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/basics/PlatformIcons/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "renderer/styles"; 3 | import PlatformIcon from "renderer/basics/PlatformIcons/PlatformIcon"; 4 | import Icon from "renderer/basics/Icon"; 5 | import { PlatformHolder, hasPlatforms } from "common/constants/platform-data"; 6 | 7 | const PlatformIconsDiv = styled.span` 8 | .icon { 9 | margin-left: 8px; 10 | 11 | &:first-child { 12 | margin-left: 0; 13 | } 14 | } 15 | `; 16 | 17 | class PlatformIcons extends React.PureComponent { 18 | render() { 19 | const { target, before, ...restProps } = this.props; 20 | if (!hasPlatforms(target)) { 21 | return null; 22 | } 23 | 24 | return ( 25 | <> 26 | {before ? before() : null} 27 | 28 | 29 | 30 | 31 | {target.type === "html" ? : null} 32 | 33 | 34 | ); 35 | } 36 | } 37 | 38 | export default PlatformIcons; 39 | 40 | interface Props { 41 | target: PlatformHolder; 42 | className?: string; 43 | before?: () => JSX.Element; 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/basics/SimpleSelect/DefaultOptionComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BaseOptionType } from "renderer/basics/SimpleSelect"; 3 | import styled from "renderer/styles"; 4 | import { T } from "renderer/t"; 5 | 6 | export interface OptionComponentProps { 7 | option: OptionType; 8 | } 9 | 10 | const DefaultOptionDiv = styled.div` 11 | display: flex; 12 | flex-flow: row; 13 | align-items: center; 14 | `; 15 | 16 | export default class DefaultOptionComponent< 17 | OptionType extends BaseOptionType 18 | > extends React.PureComponent> { 19 | render() { 20 | const { option } = this.props; 21 | return {T(option.label)}; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/basics/TotalPlaytime.tsx: -------------------------------------------------------------------------------- 1 | import { CaveSummary, Game } from "common/butlerd/messages"; 2 | import { actionForGame } from "common/util/action-for-game"; 3 | import React from "react"; 4 | import { T } from "renderer/t"; 5 | import FormattedDuration from "renderer/basics/FormattedDuration"; 6 | 7 | class TotalPlaytime extends React.PureComponent { 8 | render() { 9 | const { game, cave, short = false } = this.props; 10 | let { secondsRun = 0 } = (cave || {}) as CaveSummary; 11 | 12 | const classification = game.classification || "game"; 13 | const classAction = actionForGame(game, cave); 14 | const xed = 15 | classAction === "open" 16 | ? "opened" 17 | : classification === "game" 18 | ? "played" 19 | : "used"; 20 | 21 | if (secondsRun > 0 && classAction === "launch") { 22 | return ( 23 |
24 | {short ? null : ( 25 | 26 | )} 27 | 28 | 29 | 30 |
31 | ); 32 | } 33 | 34 | return null; 35 | } 36 | } 37 | 38 | export default TotalPlaytime; 39 | 40 | interface Props { 41 | game: Game; 42 | cave: CaveSummary; 43 | short?: boolean; 44 | secondsRun?: number; 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/basics/UploadIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Upload } from "common/butlerd/messages"; 2 | import { uploadIcon, uploadTypeHint } from "main/reactors/make-upload-button"; 3 | import React from "react"; 4 | import Icon from "renderer/basics/Icon"; 5 | 6 | class UploadIcon extends React.PureComponent { 7 | render() { 8 | const { upload } = this.props; 9 | if (!upload) { 10 | return null; 11 | } 12 | return ; 13 | } 14 | } 15 | 16 | export default UploadIcon; 17 | 18 | interface Props { 19 | upload: Upload; 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/basics/modal-styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from "renderer/styles"; 2 | 3 | export const ModalButtons = styled.div` 4 | width: 100%; 5 | flex-shrink: 0; 6 | display: flex; 7 | flex-direction: row; 8 | justify-content: flex-end; 9 | `; 10 | 11 | export const ModalButtonSpacer = styled.div` 12 | flex-shrink: 0; 13 | flex-grow: 0; 14 | width: 1em; 15 | `; 16 | -------------------------------------------------------------------------------- /src/renderer/bridge.ts: -------------------------------------------------------------------------------- 1 | import { mainWorldSupplement } from "main/inject/inject-preload"; 2 | 3 | const supplement = (window as unknown) as typeof mainWorldSupplement; 4 | 5 | export const url = { 6 | parse: supplement.nodeUrl.parse, 7 | format: supplement.nodeUrl.format, 8 | }; 9 | 10 | export const querystring = { 11 | stringify: supplement.querystring.stringify, 12 | parse: supplement.querystring.parse, 13 | }; 14 | 15 | export const electron = supplement.electron; 16 | 17 | export const useragent = { 18 | userAgent: supplement.useragent.userAgent, 19 | }; 20 | 21 | export const resources = { 22 | getImageURL: supplement.resources.getImageURL, 23 | getInjectURL: supplement.resources.getInjectURL, 24 | }; 25 | 26 | export const paths = { 27 | legacyMarketPath: supplement.paths.legacyMarketPath, 28 | mainLogPath: supplement.paths.mainLogPath, 29 | }; 30 | 31 | export const promisedFs = { 32 | readFile: supplement.promisedFs.readFile, 33 | }; 34 | 35 | export const sysinfo = { 36 | cpu: supplement.sysinfo.cpu, 37 | graphics: supplement.sysinfo.graphics, 38 | osInfo: supplement.sysinfo.osInfo, 39 | }; 40 | 41 | export const butlerd = { 42 | rcall: supplement.butlerd.rcall2, 43 | createRequest: supplement.butlerd.createRequest, 44 | }; 45 | -------------------------------------------------------------------------------- /src/renderer/butlerd/invalidators.ts: -------------------------------------------------------------------------------- 1 | import { RequestCreator } from "butlerd"; 2 | import { actions, ActionCreator } from "common/actions"; 3 | import * as messages from "common/butlerd/messages"; 4 | 5 | type MessageType = RequestCreator; 6 | export type ActionList = ActionCreator[]; 7 | 8 | export const invalidators = new Map(); 9 | 10 | invalidators.set(messages.FetchProfileOwnedKeys, [actions.commonsUpdated]); 11 | invalidators.set(messages.FetchCaves, [actions.commonsUpdated]); 12 | invalidators.set(messages.FetchCave, [actions.commonsUpdated]); 13 | invalidators.set(messages.InstallLocationsList, [ 14 | actions.commonsUpdated, 15 | actions.installLocationsChanged, 16 | ]); 17 | -------------------------------------------------------------------------------- /src/renderer/butlerd/rcall.ts: -------------------------------------------------------------------------------- 1 | import { RequestCreator } from "butlerd"; 2 | import { SetupFunc } from "common/butlerd/net"; 3 | import store from "renderer/store"; 4 | import { rendererLogger } from "renderer/logger"; 5 | import { butlerd } from "renderer/bridge"; 6 | import { Message } from "common/helpers/bridge"; 7 | 8 | const logger = rendererLogger.childWithName("rcall"); 9 | 10 | /** 11 | * Perform a butlerd call from the renderer process 12 | */ 13 | export async function rcall( 14 | rc: RequestCreator, 15 | params: {} & Params, 16 | setups?: Message[] 17 | ): Promise { 18 | return await butlerd.rcall(store, logger, rc.__method, params, setups); 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/env.ts: -------------------------------------------------------------------------------- 1 | import env from "common/env"; 2 | import { electron } from "renderer/bridge"; 3 | 4 | export default { 5 | ...env, 6 | setNodeEnv: () => env.setNodeEnv(electron.getApp()), 7 | isCanary: env.isCanary(electron.getApp()), 8 | channel: env.channel(electron.getApp()), 9 | appName: env.appName(electron.getApp()), 10 | development: env.development(electron.getApp()), 11 | production: env.production(electron.getApp()), 12 | }; 13 | -------------------------------------------------------------------------------- /src/renderer/fonts/icomoon/Read Me.txt: -------------------------------------------------------------------------------- 1 | Open *demo.html* to see a list of all the glyphs in your font along with their codes/ligatures. 2 | 3 | To use the generated font in desktop programs, you can install the TTF font. In order to copy the character associated with each icon, refer to the text box at the bottom right corner of each glyph in demo.html. The character inside this text box may be invisible; but it can still be copied. See this guide for more info: https://icomoon.io/#docs/local-fonts 4 | 5 | You won't need any of the files located under the *demo-files* directory when including the generated font in your own projects. 6 | 7 | You can import *selection.json* back to the IcoMoon app using the *Import Icons* button (or via Main Menu → Manage Projects) to retrieve your icon selection. 8 | -------------------------------------------------------------------------------- /src/renderer/fonts/icomoon/demo-files/demo.js: -------------------------------------------------------------------------------- 1 | if (!('boxShadow' in document.body.style)) { 2 | document.body.setAttribute('class', 'noBoxShadow'); 3 | } 4 | 5 | document.body.addEventListener("click", function(e) { 6 | var target = e.target; 7 | if (target.tagName === "INPUT" && 8 | target.getAttribute('class').indexOf('liga') === -1) { 9 | target.select(); 10 | } 11 | }); 12 | 13 | (function() { 14 | var fontSize = document.getElementById('fontSize'), 15 | testDrive = document.getElementById('testDrive'), 16 | testText = document.getElementById('testText'); 17 | function updateTest() { 18 | testDrive.innerHTML = testText.value || String.fromCharCode(160); 19 | if (window.icomoonLiga) { 20 | window.icomoonLiga(testDrive); 21 | } 22 | } 23 | function updateSize() { 24 | testDrive.style.fontSize = fontSize.value + 'px'; 25 | } 26 | fontSize.addEventListener('change', updateSize, false); 27 | testText.addEventListener('input', updateTest, false); 28 | testText.addEventListener('change', updateTest, false); 29 | updateSize(); 30 | }()); 31 | -------------------------------------------------------------------------------- /src/renderer/fonts/icomoon/fonts/icomoon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/icomoon/fonts/icomoon.eot -------------------------------------------------------------------------------- /src/renderer/fonts/icomoon/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/icomoon/fonts/icomoon.ttf -------------------------------------------------------------------------------- /src/renderer/fonts/icomoon/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/icomoon/fonts/icomoon.woff -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-black.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-blackitalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-blackitalic.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-bold.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-bolditalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-bolditalic.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-hairline.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-hairline.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-hairlineitalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-hairlineitalic.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-heavy.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-heavy.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-heavyitalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-heavyitalic.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-italic.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-light.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-lightitalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-lightitalic.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-medium.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-mediumitalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-mediumitalic.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-regular.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-semibold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-semibold.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-semibolditalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-semibolditalic.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-thin.woff2 -------------------------------------------------------------------------------- /src/renderer/fonts/lato/assets/lato-thinitalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchio/itch/0c80bf9bf28af1cda1c9b36696b552912aa7b2a1/src/renderer/fonts/lato/assets/lato-thinitalic.woff2 -------------------------------------------------------------------------------- /src/renderer/global-styles/hint.ts: -------------------------------------------------------------------------------- 1 | import { css, theme } from "renderer/styles"; 2 | 3 | export default css` 4 | .react-hint-container { 5 | pointer-events: none; 6 | } 7 | 8 | .react-hint__content { 9 | padding: 5px; 10 | border-radius: 2px; 11 | background: ${theme.tooltipBackground}; 12 | color: ${theme.tooltipText}; 13 | font-size: 90%; 14 | } 15 | 16 | .react-hint--top:after { 17 | border-top-color: ${theme.tooltipBackground}; 18 | } 19 | 20 | .react-hint--left:after { 21 | border-left-color: ${theme.tooltipBackground}; 22 | } 23 | 24 | .react-hint--right:after { 25 | border-right-color: ${theme.tooltipBackground}; 26 | } 27 | 28 | .react-hint--bottom:after { 29 | border-bottom-color: ${theme.tooltipBackground}; 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /src/renderer/global-styles/index.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "renderer/styles"; 2 | import reset from "renderer/global-styles/reset"; 3 | import base from "renderer/global-styles/base"; 4 | import scroll from "renderer/global-styles/scroll"; 5 | import hint from "renderer/global-styles/hint"; 6 | 7 | export default createGlobalStyle` 8 | ${reset} 9 | ${base} 10 | ${scroll} 11 | ${hint} 12 | `; 13 | -------------------------------------------------------------------------------- /src/renderer/global-styles/scroll.ts: -------------------------------------------------------------------------------- 1 | import { css } from "renderer/styles"; 2 | 3 | export default css` 4 | ::-webkit-scrollbar { 5 | width: 12px; 6 | } 7 | 8 | ::-webkit-scrollbar-thumb { 9 | border-radius: 2px; 10 | background: #aba1a1; 11 | box-shadow: inset 0 0 2px #deb5b6; 12 | } 13 | 14 | ::-webkit-scrollbar-track { 15 | border-radius: 2px; 16 | background: #443e3e; 17 | border: 1px solid #484848; 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /src/renderer/helpers/doAsync.tsx: -------------------------------------------------------------------------------- 1 | export function doAsync(f: () => Promise) { 2 | f().catch((e) => { 3 | console.error(e.stack); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/helpers/getDisplayName.ts: -------------------------------------------------------------------------------- 1 | function getDisplayName(Component: any) { 2 | return Component.displayName || Component.name || "Component"; 3 | } 4 | 5 | export default getDisplayName; 6 | -------------------------------------------------------------------------------- /src/renderer/helpers/whenClickNavigates.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from "react"; 2 | 3 | export function doesEventMeanBackground(e: MouseEvent) { 4 | if (!e) { 5 | return false; 6 | } 7 | return e.metaKey || e.ctrlKey || e.button === 1; 8 | } 9 | 10 | interface NavigationClickHandler { 11 | (opts: { background: boolean }): void; 12 | } 13 | 14 | export function whenClickNavigates( 15 | e: MouseEvent, 16 | f: NavigationClickHandler 17 | ) { 18 | // when left click or middle-click 19 | if (e.button === 0 || e.button === 1) { 20 | f({ background: doesEventMeanBackground(e) }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/hocs/withHover.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import getDisplayName from "renderer/helpers/getDisplayName"; 3 | import { Subtract } from "common/types"; 4 | 5 | interface HoverState { 6 | hover: boolean; 7 | } 8 | 9 | export interface HoverProps { 10 | hover: boolean; 11 | onMouseEnter?: React.EventHandler>; 12 | onMouseLeave?: React.EventHandler>; 13 | } 14 | 15 | function withHover( 16 | Component: React.ComponentType 17 | ) { 18 | type Props = Subtract; 19 | return class extends React.PureComponent { 20 | static displayName = `Hoverable(${getDisplayName(Component)})`; 21 | 22 | constructor(props: Props, context: any) { 23 | super(props, context); 24 | this.state = { 25 | hover: false, 26 | }; 27 | } 28 | 29 | onMouseEnter = () => { 30 | this.setState({ hover: true }); 31 | }; 32 | 33 | onMouseLeave = () => { 34 | this.setState({ hover: false }); 35 | }; 36 | 37 | render() { 38 | return ( 39 | 45 | ); 46 | } 47 | }; 48 | } 49 | 50 | export default withHover; 51 | -------------------------------------------------------------------------------- /src/renderer/hocs/withProfile.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Profile } from "common/butlerd/messages"; 3 | import { Subtract } from "common/types"; 4 | 5 | export interface ProfileContextProps { 6 | profile: Profile; 7 | } 8 | 9 | const profileContext = React.createContext(undefined); 10 | export const ProfileProvider = profileContext.Provider; 11 | export const ProfileConsumer = profileContext.Consumer; 12 | 13 | export const withProfile =

( 14 | Component: React.ComponentType

15 | ) => (props: Subtract) => ( 16 | 17 | {(profile) => ( 18 | 19 | )} 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /src/renderer/hocs/withTab.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Subtract } from "common/types"; 3 | 4 | export interface TabContextProps { 5 | tab: string; 6 | } 7 | 8 | const spaceContext = React.createContext(undefined); 9 | export const TabProvider = spaceContext.Provider; 10 | export const TabConsumer = spaceContext.Consumer; 11 | 12 | export const withTab =

( 13 | Component: React.ComponentType

14 | ) => (props: Subtract) => ( 15 | 16 | {(tab) => } 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/renderer/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { levels, LogEntry, Logger, LogSink, multiSink } from "common/logger"; 2 | import { actions } from "common/actions"; 3 | 4 | const levelColors = { 5 | default: "color:black;", 6 | 60: "background-color:red;", 7 | 50: "color:red;", 8 | 40: "color:yellow;", 9 | 30: "color:green;", 10 | 20: "color:blue;", 11 | 10: "color:grey;", 12 | } as { [key: number]: string }; 13 | 14 | const consoleSink: LogSink = { 15 | write(entry: LogEntry) { 16 | const { name, level, msg } = entry; 17 | console.log( 18 | "%c " + 19 | levels[level] + 20 | " %c" + 21 | (name ? "(" + name + ")" : "") + 22 | ":" + 23 | " %c" + 24 | msg, 25 | levelColors[level], 26 | "color:black;", 27 | "color:44e;" 28 | ); 29 | }, 30 | }; 31 | 32 | import store from "renderer/store"; 33 | 34 | const remoteSink: LogSink = { 35 | write(entry: LogEntry) { 36 | store.dispatch(actions.log({ entry })); 37 | }, 38 | }; 39 | 40 | export const rendererLogger = new Logger(multiSink(consoleSink, remoteSink)); 41 | -------------------------------------------------------------------------------- /src/renderer/modal-widgets/ExploreJson.tsx: -------------------------------------------------------------------------------- 1 | import { ExploreJsonParams, ExploreJsonResponse } from "common/modals/types"; 2 | import React from "react"; 3 | import { ModalWidgetDiv } from "renderer/modal-widgets/styles"; 4 | import styled, * as styles from "renderer/styles"; 5 | import { ModalWidgetProps } from "common/modals"; 6 | 7 | const Inspector = require("react-json-inspector"); 8 | 9 | class ExploreJson extends React.PureComponent { 10 | render() { 11 | const params = this.props.modal.widgetParams; 12 | const { data } = params; 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | } 23 | 24 | const JSONTreeContainer = styled.div` 25 | width: 100%; 26 | user-select: initial; 27 | 28 | .json-inspector__leaf_root { 29 | filter: brightness(150%); 30 | } 31 | 32 | input[type="search"] { 33 | ${styles.heavyInput}; 34 | } 35 | `; 36 | 37 | // props 38 | 39 | interface Props 40 | extends ModalWidgetProps {} 41 | 42 | export default ExploreJson; 43 | -------------------------------------------------------------------------------- /src/renderer/modal-widgets/PlanInstall/select-common.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { singleLine } from "renderer/styles"; 3 | 4 | export const SelectValueDiv = styled.div` 5 | display: flex; 6 | flex-flow: row wrap; 7 | align-items: center; 8 | 9 | .spacer { 10 | width: 0.5em; 11 | flex-shrink: 0; 12 | } 13 | 14 | .title { 15 | font-size: 90%; 16 | ${singleLine}; 17 | } 18 | 19 | .tag { 20 | color: ${(props) => props.theme.secondaryText}; 21 | text-shadow: none; 22 | 23 | font-size: 80%; 24 | padding-right: 8px; 25 | &:last-child { 26 | padding-right: 0; 27 | } 28 | 29 | border-radius: ${(props) => props.theme.borderRadii.explanation}; 30 | flex-shrink: 0; 31 | ${singleLine}; 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /src/renderer/modal-widgets/SwitchVersionCave/CustomDate.tsx: -------------------------------------------------------------------------------- 1 | import { DateFormat, DATE_FORMAT, formatDate } from "common/format/datetime"; 2 | import React from "react"; 3 | import { injectIntl, IntlShape } from "react-intl"; 4 | 5 | class CustomDate extends React.PureComponent { 6 | render() { 7 | const { intl, date, format = DATE_FORMAT } = this.props; 8 | 9 | const dateObject = new Date(date); 10 | if (!dateObject) { 11 | return null; 12 | } 13 | 14 | if (!dateObject.getTime || isNaN(dateObject.getTime())) { 15 | console.warn("CustomDate was passed an invalid date: ", this.props.date); 16 | return null; 17 | } 18 | 19 | return <>{formatDate(dateObject, intl.locale, format)}; 20 | } 21 | } 22 | 23 | interface Props { 24 | date: Date | string; 25 | format?: DateFormat; 26 | intl: IntlShape; 27 | } 28 | 29 | export default injectIntl(CustomDate); 30 | -------------------------------------------------------------------------------- /src/renderer/modal-widgets/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled, * as styles from "renderer/styles"; 2 | 3 | export const ModalWidgetDiv = styled.div` 4 | padding: 20px; 5 | flex-grow: 1; 6 | overflow-y: auto; 7 | 8 | input[type="number"], 9 | input[type="text"], 10 | input[type="password"] { 11 | ${styles.heavyInput}; 12 | width: 100%; 13 | } 14 | 15 | input[type="number"] { 16 | &::-webkit-inner-spin-button, 17 | &::-webkit-outer-spin-button { 18 | -webkit-appearance: none; 19 | margin: 0; 20 | } 21 | } 22 | 23 | strong { 24 | font-weight: bold; 25 | } 26 | 27 | p { 28 | line-height: 1.4; 29 | margin: 8px 0; 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /src/renderer/modals.ts: -------------------------------------------------------------------------------- 1 | import { prepModals } from "common/modals"; 2 | import uuid from "common/util/uuid"; 3 | import rng from "renderer/util/rng"; 4 | 5 | const modals = prepModals(() => uuid(rng)); 6 | export default modals; 7 | -------------------------------------------------------------------------------- /src/renderer/pages/BrowserPage/BrowserContext/BrowserContextConstants.tsx: -------------------------------------------------------------------------------- 1 | export const browserContextHeight = 110; 2 | -------------------------------------------------------------------------------- /src/renderer/pages/BrowserPage/newTabItems.ts: -------------------------------------------------------------------------------- 1 | import urls from "common/constants/urls"; 2 | 3 | export const newTabPrimaryItems = [ 4 | { 5 | label: ["sidebar.explore"], 6 | icon: "earth", 7 | url: "itch://featured", 8 | }, 9 | { 10 | label: ["sidebar.library"], 11 | icon: "heart-filled", 12 | url: "itch://library", 13 | }, 14 | { 15 | label: ["sidebar.collections"], 16 | icon: "video_collection", 17 | url: "itch://collections", 18 | }, 19 | { 20 | label: ["sidebar.dashboard"], 21 | icon: "archive", 22 | url: "itch://dashboard", 23 | }, 24 | ]; 25 | 26 | export const newTabSecondaryItems = [ 27 | { 28 | label: ["new_tab.random"], 29 | icon: "shuffle", 30 | url: urls.itchio + "/randomizer", 31 | }, 32 | { 33 | label: ["new_tab.on_sale"], 34 | icon: "shopping_cart", 35 | url: urls.itchio + "/games/on-sale", 36 | }, 37 | { 38 | label: ["new_tab.top_sellers"], 39 | icon: "star", 40 | url: urls.itchio + "/games/top-sellers", 41 | }, 42 | { 43 | label: ["new_tab.devlogs"], 44 | icon: "fire", 45 | url: urls.itchio + "/devlogs", 46 | }, 47 | ]; 48 | -------------------------------------------------------------------------------- /src/renderer/pages/CrashyPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MeatProps } from "renderer/scenes/HubScene/Meats/types"; 3 | 4 | export default class CrashyPage extends React.PureComponent { 5 | render() { 6 | if (1 == 1) { 7 | throw new Error("Just testing error boundaries"); 8 | } 9 | return null as JSX.Element; 10 | } 11 | } 12 | 13 | interface Props extends MeatProps {} 14 | -------------------------------------------------------------------------------- /src/renderer/pages/DashboardPage/DraftStatus.tsx: -------------------------------------------------------------------------------- 1 | import styled from "renderer/styles"; 2 | 3 | export default styled.div` 4 | font-weight: normal; 5 | text-transform: lowercase; 6 | font-size: ${(props) => props.theme.fontSizes.smaller}; 7 | color: ${(props) => props.theme.bundle}; 8 | margin-left: 0.5em; 9 | border: 1px solid; 10 | border-radius: 2px; 11 | padding: 0 4px; 12 | opacity: 0.7; 13 | `; 14 | -------------------------------------------------------------------------------- /src/renderer/pages/DashboardPage/ProfileGameStats.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ProfileGame } from "common/butlerd/messages"; 3 | import { StatBox, StatNumber } from "renderer/pages/PageStyles/stats"; 4 | import { FormattedNumber } from "react-intl"; 5 | import { T } from "renderer/t"; 6 | 7 | //----------------------------------- 8 | // Stats 9 | //----------------------------------- 10 | 11 | export default ({ pg }: { pg: ProfileGame }) => ( 12 | <> 13 | 14 | 15 | 16 | {" "} 17 | {T(["dashboard.game_stats.views"])} 18 | 19 | 20 | 21 | 22 | {" "} 23 | {T(["dashboard.game_stats.downloads"])} 24 | 25 | 26 | 27 | 28 | {" "} 29 | {T(["dashboard.game_stats.purchases"])} 30 | 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /src/renderer/pages/FeaturedPage.tsx: -------------------------------------------------------------------------------- 1 | import urls from "common/constants/urls"; 2 | import { Dispatch } from "common/types"; 3 | import React from "react"; 4 | import { hook } from "renderer/hocs/hook"; 5 | import { dispatchTabEvolve } from "renderer/hocs/tab-utils"; 6 | import { withTab } from "renderer/hocs/withTab"; 7 | import { MeatProps } from "renderer/scenes/HubScene/Meats/types"; 8 | 9 | class FeaturedPage extends React.PureComponent { 10 | render() { 11 | dispatchTabEvolve(this.props, { 12 | replace: true, 13 | url: urls.itchio, 14 | }); 15 | 16 | return null as JSX.Element; 17 | } 18 | } 19 | 20 | interface Props extends MeatProps { 21 | tab: string; 22 | dispatch: Dispatch; 23 | } 24 | 25 | export default withTab(hook()(FeaturedPage)); 26 | -------------------------------------------------------------------------------- /src/renderer/pages/InstallPage.tsx: -------------------------------------------------------------------------------- 1 | import * as messages from "common/butlerd/messages"; 2 | import { Dispatch } from "common/types"; 3 | import { ambientTab, urlForGame } from "common/util/navigation"; 4 | import React from "react"; 5 | import FiltersContainer from "renderer/basics/FiltersContainer"; 6 | import butlerCaller from "renderer/hocs/butlerCaller"; 7 | import { hookWithProps } from "renderer/hocs/hook"; 8 | import { 9 | dispatchTabEvolve, 10 | dispatchTabPageUpdate, 11 | } from "renderer/hocs/tab-utils"; 12 | import { withTab } from "renderer/hocs/withTab"; 13 | import { MeatProps } from "renderer/scenes/HubScene/Meats/types"; 14 | import { actions } from "common/actions"; 15 | 16 | class InstallPage extends React.PureComponent { 17 | componentDidMount() { 18 | const { dispatch, url, gameId } = this.props; 19 | dispatch(actions.handleItchioURI({ uri: url })); 20 | dispatchTabEvolve(this.props, { 21 | url: urlForGame(gameId), 22 | replace: true, 23 | }); 24 | } 25 | 26 | render() { 27 | return null; 28 | } 29 | } 30 | 31 | interface Props extends MeatProps { 32 | tab: string; 33 | dispatch: Dispatch; 34 | 35 | url: string; 36 | gameId: number; 37 | } 38 | 39 | export default withTab( 40 | hookWithProps(InstallPage)((map) => ({ 41 | url: map((rs, props) => ambientTab(rs, props).location.url), 42 | gameId: map((rs, props) => 43 | parseInt(ambientTab(rs, props).location.query.game_id, 10) 44 | ), 45 | }))(InstallPage) 46 | ); 47 | -------------------------------------------------------------------------------- /src/renderer/pages/PageStyles/boxes.tsx: -------------------------------------------------------------------------------- 1 | import styled, * as styles from "renderer/styles"; 2 | 3 | export const BaseBox = styled.div` 4 | margin: 1em auto; 5 | line-height: 1.6; 6 | `; 7 | 8 | export const Box = styled.div` 9 | ${styles.boxy}; 10 | max-width: 1200px; 11 | 12 | margin: 1em auto; 13 | line-height: 1.6; 14 | `; 15 | 16 | export const BoxSingle = styled.div` 17 | ${styles.boxy}; 18 | 19 | margin: 1em auto; 20 | line-height: 1.6; 21 | `; 22 | 23 | export const BoxInner = styled.div` 24 | width: 100%; 25 | display: flex; 26 | flex-direction: row; 27 | align-items: center; 28 | `; 29 | -------------------------------------------------------------------------------- /src/renderer/pages/PageStyles/games.tsx: -------------------------------------------------------------------------------- 1 | import styled from "renderer/styles"; 2 | 3 | //----------------------------------- 4 | // Description 5 | //----------------------------------- 6 | 7 | export const TitleBox = styled.div` 8 | padding: 12px 0; 9 | align-self: flex-start; 10 | 11 | display: flex; 12 | flex-direction: column; 13 | align-self: stretch; 14 | `; 15 | 16 | export const Title = styled.div` 17 | font-size: ${(props) => props.theme.fontSizes.huger}; 18 | font-weight: bold; 19 | display: flex; 20 | flex-flow: row wrap; 21 | align-items: center; 22 | `; 23 | 24 | export const TitleBreak = styled.div` 25 | flex-basis: 100%; 26 | width: 0; 27 | height: 0; 28 | overflow: hidden; 29 | margin-top: 8px; 30 | `; 31 | 32 | export const TitleSpacer = styled.div` 33 | width: 8px; 34 | `; 35 | 36 | export const Desc = styled.div` 37 | color: ${(props) => props.theme.secondaryText}; 38 | `; 39 | -------------------------------------------------------------------------------- /src/renderer/pages/PageStyles/stats.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StatBox = styled.div` 4 | padding: 0 4px; 5 | margin: 4px; 6 | margin-right: 16px; 7 | font-size: ${(props) => props.theme.fontSizes.baseText}; 8 | color: ${(props) => props.theme.secondaryText}; 9 | line-height: 1.4; 10 | font-weight: lighter; 11 | text-align: center; 12 | `; 13 | 14 | export const StatNumber = styled.div` 15 | font-size: ${(props) => props.theme.fontSizes.larger}; 16 | color: ${(props) => props.theme.baseText}; 17 | min-width: 3em; 18 | `; 19 | -------------------------------------------------------------------------------- /src/renderer/pages/PreferencesPage/BrothComponents.tsx: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import { Dispatch } from "common/types"; 3 | import React from "react"; 4 | import Icon from "renderer/basics/Icon"; 5 | import { hook } from "renderer/hocs/hook"; 6 | import BrothComponent from "renderer/pages/PreferencesPage/BrothComponent"; 7 | import { T } from "renderer/t"; 8 | 9 | class BrothComponents extends React.Component { 10 | render() { 11 | const { packageNames } = this.props; 12 | 13 | return ( 14 |

15 | {T(["preferences.advanced.components"])} 16 | 24 | {T(["menu.help.check_for_update"])} 25 | 26 | {packageNames.map((name) => ( 27 | 28 | ))} 29 |
30 | ); 31 | } 32 | 33 | checkForUpdates = () => { 34 | const { dispatch } = this.props; 35 | dispatch(actions.checkForComponentUpdates({})); 36 | }; 37 | } 38 | 39 | interface Props { 40 | dispatch: Dispatch; 41 | 42 | packageNames: string[]; 43 | } 44 | 45 | export default hook((map) => ({ 46 | packageNames: map((rs) => rs.broth.packageNames), 47 | }))(BrothComponents); 48 | -------------------------------------------------------------------------------- /src/renderer/pages/PreferencesPage/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { actions } from "common/actions"; 2 | import { Dispatch, PreferencesState } from "common/types"; 3 | import React from "react"; 4 | import { hookWithProps } from "renderer/hocs/hook"; 5 | import Label from "renderer/pages/PreferencesPage/Label"; 6 | 7 | class Checkbox extends React.PureComponent { 8 | render() { 9 | const { active, children, label } = this.props; 10 | 11 | return ( 12 | 17 | ); 18 | } 19 | 20 | onChange = (e: React.ChangeEvent) => { 21 | const { name, dispatch } = this.props; 22 | dispatch(actions.updatePreferences({ [name]: e.currentTarget.checked })); 23 | }; 24 | } 25 | 26 | interface Props { 27 | name: keyof PreferencesState; 28 | label: string | JSX.Element; 29 | children?: any; 30 | 31 | dispatch: Dispatch; 32 | active: boolean; 33 | } 34 | 35 | export default hookWithProps(Checkbox)((map) => ({ 36 | active: map((rs, props) => rs.preferences[props.name]), 37 | }))(Checkbox); 38 | -------------------------------------------------------------------------------- /src/renderer/pages/PreferencesPage/InstallLocationSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Dispatch } from "common/types"; 3 | import { hook } from "renderer/hocs/hook"; 4 | import { T } from "renderer/t"; 5 | import styled from "renderer/styles"; 6 | import Button from "renderer/basics/Button"; 7 | import { actions } from "common/actions"; 8 | 9 | const ControlButtonsDiv = styled.div` 10 | padding: 12px; 11 | padding-top: 24px; 12 | display: flex; 13 | flex-direction: row; 14 | align-items: center; 15 | `; 16 | 17 | class InstallLocationSettings extends React.Component { 18 | render() { 19 | return ( 20 | <> 21 |

{T(["preferences.install_locations"])}

22 | 23 |