Browse Source

[desktop] More fixes leading on to the release (#1632)

Manav Rathi 1 year ago
parent
commit
c4756fb847

+ 70 - 0
desktop/.github/workflows/desktop-draft-release.yml

@@ -0,0 +1,70 @@
+name: "Draft release"
+
+# Build the desktop/draft-release branch and update the existing draft release
+# with the resultant artifacts.
+#
+# This is meant for doing tests that require the app to be signed and packaged.
+# Such releases should not be published to end users.
+#
+# Workflow:
+#
+# 1. Push your changes to the "desktop/draft-release" branch on
+#    https://github.com/ente-io/ente.
+#
+# 2. Create a draft release with tag equal to the version in the `package.json`.
+#
+# 3. Trigger this workflow. You can trigger it multiple times, each time it'll
+#    just update the artifacts attached to the same draft.
+#
+# 4. Once testing is done delete the draft.
+
+on:
+    # Trigger manually or `gh workflow run desktop-draft-release.yml`.
+    workflow_dispatch:
+
+jobs:
+    release:
+        runs-on: macos-latest
+
+        defaults:
+            run:
+                working-directory: desktop
+
+        steps:
+            - name: Checkout code
+              uses: actions/checkout@v4
+              with:
+                  repository: ente-io/ente
+                  ref: desktop/draft-release
+                  submodules: recursive
+
+            - name: Setup node
+              uses: actions/setup-node@v4
+              with:
+                  node-version: 20
+
+            - name: Install dependencies
+              run: yarn install
+
+            - name: Build
+              uses: ente-io/action-electron-builder@v1.0.0
+              with:
+                  package_root: desktop
+
+                  # GitHub token, automatically provided to the action
+                  # (No need to define this secret in the repo settings)
+                  github_token: ${{ secrets.GITHUB_TOKEN }}
+
+                  # If the commit is tagged with a version (e.g. "v1.0.0"),
+                  # release the app after building.
+                  release: ${{ startsWith(github.ref, 'refs/tags/v') }}
+
+                  mac_certs: ${{ secrets.MAC_CERTS }}
+                  mac_certs_password: ${{ secrets.MAC_CERTS_PASSWORD }}
+              env:
+                  # macOS notarization credentials key details
+                  APPLE_ID: ${{ secrets.APPLE_ID }}
+                  APPLE_APP_SPECIFIC_PASSWORD:
+                      ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+                  APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+                  USE_HARD_LINKS: false

+ 0 - 1
desktop/electron-builder.yml

@@ -29,5 +29,4 @@ mac:
         arch: [universal]
     category: public.app-category.photography
     hardenedRuntime: true
-    notarize: true
 afterSign: electron-builder-notarize

+ 24 - 8
desktop/src/main.ts

@@ -142,7 +142,7 @@ const createMainWindow = () => {
     // Create the main window. This'll show our web content.
     const window = new BrowserWindow({
         webPreferences: {
-            preload: path.join(app.getAppPath(), "preload.js"),
+            preload: path.join(__dirname, "preload.js"),
             sandbox: true,
         },
         // The color to show in the window until the web content gets loaded.
@@ -287,13 +287,29 @@ const setupTrayItem = (mainWindow: BrowserWindow) => {
 
 /**
  * Older versions of our app used to maintain a cache dir using the main
- * process. This has been deprecated in favor of using a normal web cache.
+ * process. This has been removed in favor of cache on the web layer.
  *
- * Delete the old cache dir if it exists. This code was added March 2024, and
- * can be removed after some time once most people have upgraded to newer
- * versions.
+ * Delete the old cache dir if it exists.
+ *
+ * This will happen in two phases. The cache had three subdirectories:
+ *
+ * - Two of them, "thumbs" and "files", will be removed now (v1.7.0, May 2024).
+ *
+ * - The third one, "face-crops" will be removed once we finish the face search
+ *   changes. See: [Note: Legacy face crops].
+ *
+ * This migration code can be removed after some time once most people have
+ * upgraded to newer versions.
  */
 const deleteLegacyDiskCacheDirIfExists = async () => {
+    const removeIfExists = async (dirPath: string) => {
+        if (existsSync(dirPath)) {
+            log.info(`Removing legacy disk cache from ${dirPath}`);
+            await fs.rm(dirPath, { recursive: true });
+        }
+    };
+    // [Note: Getting the cache path]
+    //
     // The existing code was passing "cache" as a parameter to getPath.
     //
     // However, "cache" is not a valid parameter to getPath. It works! (for
@@ -309,8 +325,8 @@ const deleteLegacyDiskCacheDirIfExists = async () => {
     // @ts-expect-error "cache" works but is not part of the public API.
     const cacheDir = path.join(app.getPath("cache"), "ente");
     if (existsSync(cacheDir)) {
-        log.info(`Removing legacy disk cache from ${cacheDir}`);
-        await fs.rm(cacheDir, { recursive: true });
+        await removeIfExists(path.join(cacheDir, "thumbs"));
+        await removeIfExists(path.join(cacheDir, "files"));
     }
 };
 
@@ -375,7 +391,7 @@ const main = () => {
             // Continue on with the rest of the startup sequence.
             Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
             setupTrayItem(mainWindow);
-            if (!isDev) setupAutoUpdater(mainWindow);
+            setupAutoUpdater(mainWindow);
 
             try {
                 await deleteLegacyDiskCacheDirIfExists();

+ 5 - 0
desktop/src/main/ipc.ts

@@ -24,6 +24,7 @@ import {
     updateOnNextRestart,
 } from "./services/app-update";
 import {
+    legacyFaceCrop,
     openDirectory,
     openLogDirectory,
     selectDirectory,
@@ -198,6 +199,10 @@ export const attachIPCHandlers = () => {
         faceEmbedding(input),
     );
 
+    ipcMain.handle("legacyFaceCrop", (_, faceID: string) =>
+        legacyFaceCrop(faceID),
+    );
+
     // - Upload
 
     ipcMain.handle("listZipItems", (_, zipPath: string) =>

+ 3 - 9
desktop/src/main/menu.ts

@@ -10,7 +10,6 @@ import { forceCheckForAppUpdates } from "./services/app-update";
 import autoLauncher from "./services/auto-launcher";
 import { openLogDirectory } from "./services/dir";
 import { userPreferences } from "./stores/user-preferences";
-import { isDev } from "./utils/electron";
 
 /** Create and return the entries in the app's main menu bar */
 export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
@@ -24,9 +23,6 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
     const macOSOnly = (options: MenuItemConstructorOptions[]) =>
         process.platform == "darwin" ? options : [];
 
-    const devOnly = (options: MenuItemConstructorOptions[]) =>
-        isDev ? options : [];
-
     const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
 
     const handleViewChangelog = () =>
@@ -130,11 +126,11 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
                         submenu: [
                             {
                                 role: "startSpeaking",
-                                label: "start speaking",
+                                label: "Start Speaking",
                             },
                             {
                                 role: "stopSpeaking",
-                                label: "stop speaking",
+                                label: "Stop Speaking",
                             },
                         ],
                     },
@@ -145,9 +141,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
             label: "View",
             submenu: [
                 { label: "Reload", role: "reload" },
-                ...devOnly([
-                    { label: "Toggle Dev Tools", role: "toggleDevTools" },
-                ]),
+                { label: "Toggle Dev Tools", role: "toggleDevTools" },
                 { type: "separator" },
                 { label: "Toggle Full Screen", role: "togglefullscreen" },
             ],

+ 9 - 0
desktop/src/main/services/app-update.ts

@@ -6,11 +6,20 @@ import { allowWindowClose } from "../../main";
 import { AppUpdate } from "../../types/ipc";
 import log from "../log";
 import { userPreferences } from "../stores/user-preferences";
+import { isDev } from "../utils/electron";
 
 export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
     autoUpdater.logger = electronLog;
     autoUpdater.autoDownload = false;
 
+    // Skip checking for updates automatically in dev builds. Installing an
+    // update would fail anyway since (at least on macOS), the auto update
+    // process requires signed builds.
+    //
+    // Even though this is skipped on app start, we can still use the "Check for
+    // updates..." menu option to trigger the update if we wish in dev builds.
+    if (isDev) return;
+
     const oneDay = 1 * 24 * 60 * 60 * 1000;
     setInterval(() => void checkForUpdatesAndNotify(mainWindow), oneDay);
     void checkForUpdatesAndNotify(mainWindow);

+ 5 - 6
desktop/src/main/services/auto-launcher.ts

@@ -27,14 +27,14 @@ class AutoLauncher {
     }
 
     async toggleAutoLaunch() {
-        const isEnabled = await this.isEnabled();
+        const wasEnabled = await this.isEnabled();
         const autoLaunch = this.autoLaunch;
         if (autoLaunch) {
-            if (isEnabled) await autoLaunch.disable();
+            if (wasEnabled) await autoLaunch.disable();
             else await autoLaunch.enable();
         } else {
-            if (isEnabled) app.setLoginItemSettings({ openAtLogin: false });
-            else app.setLoginItemSettings({ openAtLogin: true });
+            const openAtLogin = !wasEnabled;
+            app.setLoginItemSettings({ openAtLogin });
         }
     }
 
@@ -42,8 +42,7 @@ class AutoLauncher {
         if (this.autoLaunch) {
             return app.commandLine.hasSwitch("hidden");
         } else {
-            // TODO(MR): This apparently doesn't work anymore.
-            return app.getLoginItemSettings().wasOpenedAtLogin;
+            return app.getLoginItemSettings().openAtLogin;
         }
     }
 }

+ 43 - 5
desktop/src/main/services/dir.ts

@@ -1,5 +1,7 @@
 import { shell } from "electron/common";
 import { app, dialog } from "electron/main";
+import { existsSync } from "fs";
+import fs from "node:fs/promises";
 import path from "node:path";
 import { posixPath } from "../utils/electron";
 
@@ -38,14 +40,50 @@ export const openLogDirectory = () => openDirectory(logDirectoryPath());
  *
  * [Note: Electron app paths]
  *
- * By default, these paths are at the following locations:
+ * There are three paths we need to be aware of usually.
  *
- * - macOS: `~/Library/Application Support/ente`
+ * First is the "appData". We can obtain this with `app.getPath("appData")`.
+ * This is per-user application data directory. This is usually the following:
+ *
+ * - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local`
+ * - Linux: `~/.config`
+ * - macOS: `~/Library/Application Support`
+ *
+ * Now, if we suffix the app's name onto the appData directory, we get the
+ * "userData" directory. This is the **primary** place applications are meant to
+ * store user's data, e.g. various configuration files and saved state.
+ *
+ * During development, our app name is "Electron", so this'd be, for example,
+ * `~/Library/Application Support/Electron` if we run using `yarn dev`. For the
+ * packaged production app, our app name is "ente", so this would be:
+ *
+ * - Windows: `%APPDATA%\ente`, e.g. `C:\Users\<username>\AppData\Local\ente`
  * - Linux: `~/.config/ente`
- * - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local\ente`
- * - Windows: C:\Users\<you>\AppData\Local\<Your App Name>
+ * - macOS: `~/Library/Application Support/ente`
  *
- * https://www.electronjs.org/docs/latest/api/app
+ * Note that Chromium also stores the browser state, e.g. localStorage or disk
+ * caches, in userData.
  *
+ * Finally, there is the "logs" directory. This is not within "appData" but has
+ * a slightly different OS specific path. Since our log file is named
+ * "ente.log", it can be found at:
+ *
+ * - macOS: ~/Library/Logs/ente/ente.log (production)
+ * - macOS: ~/Library/Logs/Electron/ente.log    (dev)
+ *
+ * https://www.electronjs.org/docs/latest/api/app
  */
 const logDirectoryPath = () => app.getPath("logs");
+
+/**
+ * See: [Note: Legacy face crops]
+ */
+export const legacyFaceCrop = async (
+    faceID: string,
+): Promise<Uint8Array | undefined> => {
+    // See: [Note: Getting the cache path]
+    // @ts-expect-error "cache" works but is not part of the public API.
+    const cacheDir = path.join(app.getPath("cache"), "ente");
+    const filePath = path.join(cacheDir, "face-crops", faceID);
+    return existsSync(filePath) ? await fs.readFile(filePath) : undefined;
+};

+ 9 - 0
desktop/src/main/services/store.ts

@@ -14,6 +14,15 @@ export const clearStores = () => {
     watchStore.clear();
 };
 
+/**
+ * [Note: Safe storage keys]
+ *
+ * On macOS, `safeStorage` stores our data under a Keychain entry named
+ * "<app-name> Safe Storage". Which resolves to:
+ *
+ * - Electron Safe Storage (dev)
+ * - ente Safe Storage    (prod)
+ */
 export const saveEncryptionKey = (encryptionKey: string) => {
     const encryptedKey = safeStorage.encryptString(encryptionKey);
     const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");

+ 4 - 0
desktop/src/preload.ts

@@ -164,6 +164,9 @@ const detectFaces = (input: Float32Array) =>
 const faceEmbedding = (input: Float32Array) =>
     ipcRenderer.invoke("faceEmbedding", input);
 
+const legacyFaceCrop = (faceID: string) =>
+    ipcRenderer.invoke("legacyFaceCrop", faceID);
+
 // - Watch
 
 const watchGet = () => ipcRenderer.invoke("watchGet");
@@ -341,6 +344,7 @@ contextBridge.exposeInMainWorld("electron", {
     clipTextEmbeddingIfAvailable,
     detectFaces,
     faceEmbedding,
+    legacyFaceCrop,
 
     // - Watch
 

+ 2 - 2
web/apps/photos/src/components/ml/MLSearchSettings.tsx

@@ -22,7 +22,7 @@ import {
     getFaceSearchEnabledStatus,
     updateFaceSearchEnabledStatus,
 } from "services/userService";
-import { isInternalUser } from "utils/user";
+import { isInternalUserForML } from "utils/user";
 
 export const MLSearchSettings = ({ open, onClose, onRootClose }) => {
     const {
@@ -280,7 +280,7 @@ function EnableMLSearch({ onClose, enableMlSearch, onRootClose }) {
                         </p>
                     </Typography>
                 </Box>
-                {isInternalUser() && (
+                {isInternalUserForML() && (
                     <Stack px={"8px"} spacing={"8px"}>
                         <Button
                             color={"accent"}

+ 17 - 12
web/apps/photos/src/components/ml/PeopleList.tsx

@@ -1,11 +1,8 @@
-import { cachedOrNew } from "@/next/blob-cache";
-import { ensureLocalUser } from "@/next/local-user";
 import log from "@/next/log";
 import { Skeleton, styled } from "@mui/material";
 import { Legend } from "components/PhotoViewer/styledComponents/Legend";
 import { t } from "i18next";
 import React, { useEffect, useState } from "react";
-import machineLearningService from "services/machineLearning/machineLearningService";
 import { EnteFile } from "types/file";
 import { Face, Person } from "types/machineLearning";
 import { getPeopleList, getUnidentifiedFaces } from "utils/machineLearning";
@@ -61,7 +58,7 @@ export const PeopleList = React.memo((props: PeopleListProps) => {
                     }
                 >
                     <FaceCropImageView
-                        faceId={person.displayFaceId}
+                        faceID={person.displayFaceId}
                         cacheKey={person.faceCropCacheKey}
                     />
                 </FaceChip>
@@ -140,7 +137,7 @@ export function UnidentifiedFaces(props: {
                     faces.map((face, index) => (
                         <FaceChip key={index}>
                             <FaceCropImageView
-                                faceId={face.id}
+                                faceID={face.id}
                                 cacheKey={face.crop?.cacheKey}
                             />
                         </FaceChip>
@@ -151,20 +148,24 @@ export function UnidentifiedFaces(props: {
 }
 
 interface FaceCropImageViewProps {
-    faceId: string;
+    faceID: string;
     cacheKey?: string;
 }
 
 const FaceCropImageView: React.FC<FaceCropImageViewProps> = ({
-    faceId,
+    faceID,
     cacheKey,
 }) => {
     const [objectURL, setObjectURL] = useState<string | undefined>();
 
     useEffect(() => {
         let didCancel = false;
+        const electron = globalThis.electron;
 
-        if (cacheKey) {
+        if (faceID && electron) {
+            electron
+                .legacyFaceCrop(faceID)
+                /*
             cachedOrNew("face-crops", cacheKey, async () => {
                 const user = await ensureLocalUser();
                 return machineLearningService.regenerateFaceCrop(
@@ -172,16 +173,20 @@ const FaceCropImageView: React.FC<FaceCropImageViewProps> = ({
                     user.id,
                     faceId,
                 );
-            }).then((blob) => {
-                if (!didCancel) setObjectURL(URL.createObjectURL(blob));
-            });
+            })*/
+                .then((data) => {
+                    if (data) {
+                        const blob = new Blob([data]);
+                        if (!didCancel) setObjectURL(URL.createObjectURL(blob));
+                    }
+                });
         } else setObjectURL(undefined);
 
         return () => {
             didCancel = true;
             if (objectURL) URL.revokeObjectURL(objectURL);
         };
-    }, [faceId, cacheKey]);
+    }, [faceID, cacheKey]);
 
     return objectURL ? (
         <img src={objectURL} />

+ 2 - 2
web/apps/photos/src/utils/machineLearning/config.ts

@@ -10,7 +10,7 @@ import mlIDbStorage, {
     ML_SYNC_CONFIG_NAME,
     ML_SYNC_JOB_CONFIG_NAME,
 } from "utils/storage/mlIDbStorage";
-import { isInternalUser } from "utils/user";
+import { isInternalUserForML } from "utils/user";
 
 export async function getMLSyncJobConfig() {
     return mlIDbStorage.getConfig(
@@ -24,7 +24,7 @@ export async function getMLSyncConfig() {
 }
 
 export async function getMLSearchConfig() {
-    if (isInternalUser()) {
+    if (isInternalUserForML()) {
         return mlIDbStorage.getConfig(
             ML_SEARCH_CONFIG_NAME,
             DEFAULT_ML_SEARCH_CONFIG,

+ 9 - 3
web/apps/photos/src/utils/user/index.ts

@@ -1,4 +1,5 @@
 import { getData, LS_KEYS } from "@ente/shared/storage/localStorage";
+import type { User } from "@ente/shared/user/types";
 import { UserDetails } from "types/user";
 
 export function getLocalUserDetails(): UserDetails {
@@ -9,7 +10,12 @@ export const isInternalUser = () => {
     const userEmail = getData(LS_KEYS.USER)?.email;
     if (!userEmail) return false;
 
-    return (
-        userEmail.endsWith("@ente.io") || userEmail === "kr.anand619@gmail.com"
-    );
+    return userEmail.endsWith("@ente.io");
+};
+
+export const isInternalUserForML = () => {
+    const userId = (getData(LS_KEYS.USER) as User)?.id;
+    if (userId == 1) return true;
+
+    return isInternalUser();
 };

+ 22 - 0
web/packages/next/types/ipc.ts

@@ -346,6 +346,28 @@ export interface Electron {
      */
     faceEmbedding: (input: Float32Array) => Promise<Float32Array>;
 
+    /**
+     * Return a face crop stored by a previous version of ML.
+     *
+     * [Note: Legacy face crops]
+     *
+     * Older versions of ML generated and stored face crops in a "face-crops"
+     * cache directory on the Electron side. For the time being, we have
+     * disabled the face search whilst we put finishing touches to it. However,
+     * it'll be nice to still show the existing faces that have been clustered
+     * for people who opted in to the older beta.
+     *
+     * So we retain the older "face-crops" disk cache, and use this method to
+     * serve faces from it when needed.
+     *
+     * @param faceID An identifier corresponding to which the face crop had been
+     * stored by the older version of our app.
+     *
+     * @returns the JPEG data of the face crop if a file is found for the given
+     * {@link faceID}, otherwise undefined.
+     */
+    legacyFaceCrop: (faceID: string) => Promise<Uint8Array | undefined>;
+
     // - Watch
 
     /**