diff --git a/.envrc b/.envrc new file mode 100644 index 0000000000..2d3a786b3b --- /dev/null +++ b/.envrc @@ -0,0 +1,7 @@ +strict_env + +if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4=" +fi + +use flake diff --git a/.gitignore b/.gitignore index 4319eb7e57..39beaf549b 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,9 @@ test-results *.ir *.meta .enso/ + +################## +## direnv cache ## +################## + +.direnv diff --git a/app/ide-desktop/lib/common/package.json b/app/ide-desktop/lib/common/package.json index 8790ee4936..ccd5db0e9a 100644 --- a/app/ide-desktop/lib/common/package.json +++ b/app/ide-desktop/lib/common/package.json @@ -8,6 +8,7 @@ "./src/appConfig": "./src/appConfig.js", "./src/buildUtils": "./src/buildUtils.js", "./src/detect": "./src/detect.ts", - "./src/gtag": "./src/gtag.ts" + "./src/gtag": "./src/gtag.ts", + "./src/load": "./src/load.ts" } } diff --git a/app/ide-desktop/lib/common/src/appConfig.js b/app/ide-desktop/lib/common/src/appConfig.js index 441e0f3d20..eea34420e9 100644 --- a/app/ide-desktop/lib/common/src/appConfig.js +++ b/app/ide-desktop/lib/common/src/appConfig.js @@ -3,6 +3,12 @@ import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as url from 'node:url' +// ================= +// === Constants === +// ================= + +const ENSO_CLOUD_GOOGLE_ANALYTICS_TAG = 'G-CLTBJ37MDM' + // =============================== // === readEnvironmentFromFile === // =============================== @@ -35,6 +41,10 @@ export async function readEnvironmentFromFile() { } const variables = Object.fromEntries(entries) Object.assign(process.env, variables) + if (!('' in process.env)) { + // @ts-expect-error This is the only place where this environment variable is set. + process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG = ENSO_CLOUD_GOOGLE_ANALYTICS_TAG + } } catch (error) { if (isProduction) { console.warn('Could not load `.env` file; disabling cloud backend.') diff --git a/app/ide-desktop/lib/common/src/detect.ts b/app/ide-desktop/lib/common/src/detect.ts index e7f1857bb1..a2f97df145 100644 --- a/app/ide-desktop/lib/common/src/detect.ts +++ b/app/ide-desktop/lib/common/src/detect.ts @@ -17,15 +17,27 @@ export enum Platform { windows = 'Windows', macOS = 'macOS', linux = 'Linux', + windowsPhone = 'Windows Phone', + iPhoneOS = 'iPhone OS', + android = 'Android', } -/** Return the platform the app is currently running on. +/** The platform the app is currently running on. * This is used to determine whether `metaKey` or `ctrlKey` is used in shortcuts. */ -export function platform(): Platform { - if (isOnWindows()) { +export function platform() { + if (isOnWindowsPhone()) { + // MUST be before Android and Windows. + return Platform.windowsPhone + } else if (isOnWindows()) { return Platform.windows + } else if (isOnIPhoneOS()) { + // MUST be before macOS. + return Platform.iPhoneOS } else if (isOnMacOS()) { return Platform.macOS + } else if (isOnAndroid()) { + // MUST be before Linux. + return Platform.android } else if (isOnLinux()) { return Platform.linux } else { @@ -33,22 +45,37 @@ export function platform(): Platform { } } -/** Return whether the device is running Windows. */ +/** Whether the device is running Windows. */ export function isOnWindows() { return /windows/i.test(navigator.userAgent) } -/** Return whether the device is running macOS. */ +/** Whether the device is running macOS. */ export function isOnMacOS() { return /mac os/i.test(navigator.userAgent) } -/** Return whether the device is running Linux. */ +/** Whether the device is running Linux. */ export function isOnLinux() { return /linux/i.test(navigator.userAgent) } -/** Return whether the device is running an unknown OS. */ +/** Whether the device is running Windows Phone. */ +export function isOnWindowsPhone() { + return /windows phone/i.test(navigator.userAgent) +} + +/** Whether the device is running iPhone OS. */ +export function isOnIPhoneOS() { + return /iPhone/i.test(navigator.userAgent) +} + +/** Whether the device is running Android. */ +export function isOnAndroid() { + return /android/i.test(navigator.userAgent) +} + +/** Whether the device is running an unknown OS. */ export function isOnUnknownOS() { return platform() === Platform.unknown } @@ -126,3 +153,73 @@ export function isOnSafari() { export function isOnUnknownBrowser() { return browser() === Browser.unknown } + +// ==================== +// === Architecture === +// ==================== + +let detectedArchitecture: string | null = null +// Only implemented by Chromium. +// @ts-expect-error This API exists, but no typings exist for it yet. +// eslint-disable-next-line @typescript-eslint/no-unsafe-call +navigator.userAgentData + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ?.getHighEntropyValues(['architecture']) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + .then((values: unknown) => { + if ( + typeof values === 'object' && + values != null && + 'architecture' in values && + typeof values.architecture === 'string' + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + detectedArchitecture = String(values.architecture) + } + }) + +/** Possible processor architectures. */ +export enum Architecture { + intel64 = 'x86_64', + arm64 = 'arm64', +} + +/** The processor architecture of the current system. */ +export function architecture() { + if (detectedArchitecture != null) { + switch (detectedArchitecture) { + case 'arm': { + return Architecture.arm64 + } + default: { + return Architecture.intel64 + } + } + } + switch (platform()) { + case Platform.windows: + case Platform.linux: + case Platform.unknown: { + return Architecture.intel64 + } + case Platform.macOS: + case Platform.iPhoneOS: + case Platform.android: + case Platform.windowsPhone: { + // Assume the macOS device is on a M-series CPU. + // This is highly unreliable, but operates under the assumption that all + // new macOS devices will be ARM64. + return Architecture.arm64 + } + } +} + +/** Whether the device has an Intel 64-bit CPU. */ +export function isIntel64() { + return architecture() === Architecture.intel64 +} + +/** Whether the device has an ARM 64-bit CPU. */ +export function isArm64() { + return architecture() === Architecture.arm64 +} diff --git a/app/ide-desktop/lib/common/src/gtag.ts b/app/ide-desktop/lib/common/src/gtag.ts index d69abb1532..591b102167 100644 --- a/app/ide-desktop/lib/common/src/gtag.ts +++ b/app/ide-desktop/lib/common/src/gtag.ts @@ -1,4 +1,11 @@ /** @file Google Analytics tag. */ +import * as load from './load' + +if (process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG != null) { + void load.loadScript( + `https://www.googletagmanager.com/gtag/js?id=${process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG}` + ) +} // @ts-expect-error This is explicitly not given types as it is a mistake to acess this // anywhere else. diff --git a/app/ide-desktop/lib/common/src/load.ts b/app/ide-desktop/lib/common/src/load.ts new file mode 100644 index 0000000000..30fbe1fe30 --- /dev/null +++ b/app/ide-desktop/lib/common/src/load.ts @@ -0,0 +1,32 @@ +/** @file Utilities for loading resources. */ + +/** Add a script to the DOM. */ +export function loadScript(url: string) { + const script = document.createElement('script') + script.crossOrigin = 'anonymous' + script.src = url + document.head.appendChild(script) + return new Promise((resolve, reject) => { + script.onload = () => { + resolve(script) + } + script.onerror = reject + }) +} + +/** Add a CSS stylesheet to the DOM. */ +export function loadStyle(url: string) { + const style = document.createElement('link') + style.crossOrigin = 'anonymous' + style.href = url + style.rel = 'stylesheet' + style.media = 'screen' + style.type = 'text/css' + document.head.appendChild(style) + return new Promise((resolve, reject) => { + style.onload = () => { + resolve(style) + } + style.onerror = reject + }) +} diff --git a/app/ide-desktop/lib/dashboard/404.html b/app/ide-desktop/lib/dashboard/404.html index cede5ba706..bd5e545fbd 100644 --- a/app/ide-desktop/lib/dashboard/404.html +++ b/app/ide-desktop/lib/dashboard/404.html @@ -42,10 +42,5 @@ - - diff --git a/app/ide-desktop/lib/dashboard/index.html b/app/ide-desktop/lib/dashboard/index.html index ec5439bd1c..cba3495f81 100644 --- a/app/ide-desktop/lib/dashboard/index.html +++ b/app/ide-desktop/lib/dashboard/index.html @@ -43,10 +43,5 @@ - - diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx index 3be2a8111b..35cb0bb2b9 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx @@ -70,6 +70,9 @@ const MODIFIER_JSX: Readonly< ), }, + [detect.Platform.iPhoneOS]: {}, + [detect.Platform.android]: {}, + [detect.Platform.windowsPhone]: {}, /* eslint-enable @typescript-eslint/naming-convention */ } diff --git a/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts index 4f98f71273..245fd560a7 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts @@ -5,9 +5,9 @@ import * as gtag from 'enso-common/src/gtag' import * as authProvider from '#/providers/AuthProvider' -// =================== -// === useGtag === -// =================== +// ==================== +// === useGtagEvent === +// ==================== /** A hook that returns a no-op if the user is offline, otherwise it returns * a transparent wrapper around `gtag.event`. */ @@ -22,3 +22,28 @@ export function useGtagEvent() { [sessionType] ) } + +// ============================= +// === gtagOpenCloseCallback === +// ============================= + +/** Send an event indicating that something has been opened, and return a cleanup function + * sending an event indicating that it has been closed. + * + * Also sends the close event when the window is unloaded. */ +export function gtagOpenCloseCallback( + gtagEventRef: React.MutableRefObject>, + openEvent: string, + closeEvent: string +) { + const gtagEventCurrent = gtagEventRef.current + gtagEventCurrent(openEvent) + const onBeforeUnload = () => { + gtagEventCurrent(closeEvent) + } + window.addEventListener('beforeunload', onBeforeUnload) + return () => { + window.removeEventListener('beforeunload', onBeforeUnload) + gtagEventCurrent(closeEvent) + } +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx index 736e83516f..ddbb30057f 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx @@ -243,7 +243,6 @@ interface InternalChatHeaderProps { function ChatHeader(props: InternalChatHeaderProps) { const { threads, setThreads, threadId, threadTitle, setThreadTitle } = props const { switchThread, sendMessage, doClose } = props - const gtagEvent = gtagHooks.useGtagEvent() const [isThreadListVisible, setIsThreadListVisible] = React.useState(false) // These will never be `null` as their values are set immediately. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -258,12 +257,10 @@ function ChatHeader(props: InternalChatHeaderProps) { setIsThreadListVisible(false) } document.addEventListener('click', onClick) - gtagEvent('cloud_open_chat') return () => { document.removeEventListener('click', onClick) - gtagEvent('cloud_close_chat') } - }, [gtagEvent]) + }, []) return ( <> @@ -394,6 +391,17 @@ export default function Chat(props: ChatProps) { } }, }) + const gtagEvent = gtagHooks.useGtagEvent() + const gtagEventRef = React.useRef(gtagEvent) + gtagEventRef.current = gtagEvent + + React.useEffect(() => { + if (!isOpen) { + return + } else { + return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'cloud_open_chat', 'cloud_close_chat') + } + }, [isOpen]) /** This is SAFE, because this component is only rendered when `accessToken` is present. * See `dashboard.tsx` for its sole usage. */ diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx index cfb7b9b0a2..4d469defdc 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx @@ -1,14 +1,15 @@ /** @file The container that launches the IDE. */ import * as React from 'react' +import * as load from 'enso-common/src/load' + import * as appUtils from '#/appUtils' +import * as gtagHooks from '#/hooks/gtagHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as backendModule from '#/services/Backend' -import * as load from '#/utilities/load' - // ================= // === Constants === // ================= @@ -39,6 +40,9 @@ export interface EditorProps { export default function Editor(props: EditorProps) { const { hidden, supportsLocalBackend, projectStartupInfo, appRunner } = props const toastAndLog = toastAndLogHooks.useToastAndLog() + const gtagEvent = gtagHooks.useGtagEvent() + const gtagEventRef = React.useRef(gtagEvent) + gtagEventRef.current = gtagEvent const [initialized, setInitialized] = React.useState(supportsLocalBackend) React.useEffect(() => { @@ -48,6 +52,14 @@ export default function Editor(props: EditorProps) { } }, [hidden]) + React.useEffect(() => { + if (hidden) { + return + } else { + return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_workflow', 'close_workflow') + } + }, [projectStartupInfo, hidden]) + let hasEffectRun = false React.useEffect(() => { diff --git a/app/ide-desktop/lib/dashboard/src/pages/subscribe/Subscribe.tsx b/app/ide-desktop/lib/dashboard/src/pages/subscribe/Subscribe.tsx index 9d4498ecd8..c4e6474d05 100644 --- a/app/ide-desktop/lib/dashboard/src/pages/subscribe/Subscribe.tsx +++ b/app/ide-desktop/lib/dashboard/src/pages/subscribe/Subscribe.tsx @@ -6,6 +6,8 @@ import type * as stripeTypes from '@stripe/stripe-js' import * as stripe from '@stripe/stripe-js/pure' import * as toast from 'react-toastify' +import * as load from 'enso-common/src/load' + import * as appUtils from '#/appUtils' import type * as text from '#/text' @@ -20,7 +22,6 @@ import UnstyledButton from '#/components/UnstyledButton' import * as backendModule from '#/services/Backend' -import * as load from '#/utilities/load' import * as string from '#/utilities/string' // ================= diff --git a/app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx b/app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx index 00fb4b8629..52da3a129d 100644 --- a/app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx +++ b/app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx @@ -10,10 +10,13 @@ import isNetworkError from 'is-network-error' import * as router from 'react-router-dom' import * as toast from 'react-toastify' +import * as detect from 'enso-common/src/detect' import * as gtag from 'enso-common/src/gtag' import * as appUtils from '#/appUtils' +import * as gtagHooks from '#/hooks/gtagHooks' + import * as backendProvider from '#/providers/BackendProvider' import * as localStorageProvider from '#/providers/LocalStorageProvider' import * as loggerProvider from '#/providers/LoggerProvider' @@ -238,6 +241,16 @@ export default function AuthProvider(props: AuthProviderProps) { }, [userSession?.type] ) + const gtagEventRef = React.useRef(gtagEvent) + gtagEventRef.current = gtagEvent + + React.useEffect(() => { + gtag.gtag('set', { + platform: detect.platform(), + architecture: detect.architecture(), + }) + return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_app', 'close_app') + }, []) // This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible // circular dependency. diff --git a/app/ide-desktop/lib/dashboard/src/utilities/load.ts b/app/ide-desktop/lib/dashboard/src/utilities/load.ts deleted file mode 100644 index 904f07686b..0000000000 --- a/app/ide-desktop/lib/dashboard/src/utilities/load.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** @file Utilities for loading resources. */ - -/** Add a script to the DOM. */ -export function loadScript(url: string) { - const script = document.createElement('script') - script.crossOrigin = 'anonymous' - script.src = url - document.head.appendChild(script) - return new Promise((resolve, reject) => { - script.onload = () => { - resolve(script) - } - script.onerror = reject - }) -} - -/** Add a CSS stylesheet to the DOM. */ -export function loadStyle(url: string) { - const style = document.createElement('link') - style.crossOrigin = 'anonymous' - style.href = url - style.rel = 'stylesheet' - style.media = 'screen' - style.type = 'text/css' - document.head.appendChild(style) - return new Promise((resolve, reject) => { - style.onload = () => { - resolve(style) - } - style.onerror = reject - }) -} diff --git a/app/ide-desktop/lib/types/globals.d.ts b/app/ide-desktop/lib/types/globals.d.ts index 604c576c53..f8d9174a12 100644 --- a/app/ide-desktop/lib/types/globals.d.ts +++ b/app/ide-desktop/lib/types/globals.d.ts @@ -140,6 +140,8 @@ declare global { // @ts-expect-error The index signature is intentional to disallow unknown env vars. readonly ENSO_CLOUD_COGNITO_REGION?: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. + readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string + // @ts-expect-error The index signature is intentional to disallow unknown env vars. readonly ENSO_SUPPORTS_VIBRANCY?: string // === Electron watch script variables === diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..022b048f52 --- /dev/null +++ b/flake.lock @@ -0,0 +1,64 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": ["nixpkgs"], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1713939967, + "narHash": "sha256-3YQSEYvAIHE40tx5nM9dgeEe0gsHjf15+gurUpyDYNw=", + "owner": "nix-community", + "repo": "fenix", + "rev": "5c3ff469526a6ca54a887fbda9d67aef4dd4a921", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1713805509, + "narHash": "sha256-YgSEan4CcrjivCNO5ZNzhg7/8ViLkZ4CB/GrGBVSudo=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "1e1dc66fe68972a76679644a5577828b6a7e8be4", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1713801366, + "narHash": "sha256-VmzP5s59kb6//mj+ES+hslTLuugjd7OluGIXXcwuyHg=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "e31c9f3fe11148514c3ad254b639b2ed7dbe35de", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..2e8846e233 --- /dev/null +++ b/flake.nix @@ -0,0 +1,46 @@ +{ + inputs = { + nixpkgs.url = github:nixos/nixpkgs/nixpkgs-unstable; + fenix.url = github:nix-community/fenix; + fenix.inputs.nixpkgs.follows = "nixpkgs"; + }; + outputs = { self, nixpkgs, fenix }: + let + forAllSystems = with nixpkgs.lib; f: foldAttrs mergeAttrs { } + (map (s: { ${s} = f s; }) systems.flakeExposed); + in + { + devShell = forAllSystems + (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + rust = fenix.packages.${system}.fromToolchainFile { + dir = ./.; + sha256 = "sha256-o/MRwGYjLPyD1zZQe3LX0dOynwRJpVygfF9+vSnqTOc="; + }; + in + pkgs.mkShell { + packages = with pkgs; [ + # === TypeScript dependencies === + nodejs_20 # should match the Node.JS version of the lambdas + corepack + # === Electron === + electron + # === node-gyp dependencies === + python3 + gnumake + # === WASM parser dependencies === + rust + wasm-pack + # Java and SBT omitted for now + ]; + + shellHook = '' + # `sccache` can be used to speed up compile times for Rust crates. + # `~/.cargo/bin/sccache` is provided by `cargo install sccache`. + # `~/.cargo/bin` must be in the `PATH` for the binary to be accessible. + export PATH=$HOME/.cargo/bin:$PATH + ''; + }); + }; +}