diff --git a/app/ide-desktop/lib/client/src/bin/server.ts b/app/ide-desktop/lib/client/src/bin/server.ts index afbaeb8ddf7..bebd8d22cef 100644 --- a/app/ide-desktop/lib/client/src/bin/server.ts +++ b/app/ide-desktop/lib/client/src/bin/server.ts @@ -3,7 +3,6 @@ import * as fs from 'node:fs/promises' import * as fsSync from 'node:fs' import * as http from 'node:http' -import * as os from 'node:os' import * as path from 'node:path' import * as stream from 'node:stream' import * as mkcert from 'mkcert' @@ -17,6 +16,7 @@ import * as common from 'enso-common' import GLOBAL_CONFIG from 'enso-common/src/config.json' assert { type: 'json' } import * as contentConfig from 'enso-content-config' import * as ydocServer from 'enso-gui2/ydoc-server' +import * as projectManagement from 'enso-project-manager-shim/src/projectManagement' import * as paths from '../paths' @@ -92,7 +92,7 @@ export class Server { /** Create a simple HTTP server. */ constructor(public config: Config) { - this.projectsRootDirectory = path.join(os.homedir(), 'enso/projects') + this.projectsRootDirectory = projectManagement.getProjectsDirectory() } /** Server constructor. */ diff --git a/app/ide-desktop/lib/project-manager-shim/package.json b/app/ide-desktop/lib/project-manager-shim/package.json index 7ea1db9bf7d..4fb055ea882 100644 --- a/app/ide-desktop/lib/project-manager-shim/package.json +++ b/app/ide-desktop/lib/project-manager-shim/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "type": "module", "exports": { - ".": "./src/projectManagerShimMiddleware.ts" + ".": "./src/projectManagerShimMiddleware.ts", + "./src/projectManagement": "./src/projectManagement.ts" }, "dependencies": { "yaml": "^2.4.1" diff --git a/app/ide-desktop/lib/project-manager-shim/src/desktopEnvironment.ts b/app/ide-desktop/lib/project-manager-shim/src/desktopEnvironment.ts new file mode 100644 index 00000000000..760a8dd942b --- /dev/null +++ b/app/ide-desktop/lib/project-manager-shim/src/desktopEnvironment.ts @@ -0,0 +1,81 @@ +/** + * @file This module contains the logic for the detection of user-specific desktop environment attributes. + */ + +import * as childProcess from 'node:child_process' +import * as os from 'node:os' +import * as path from 'node:path' + +export const DOCUMENTS = getDocumentsPath() + +const CHILD_PROCESS_TIMEOUT = 3000 + +/** + * Detects path of the user documents directory depending on the operating system. + */ +function getDocumentsPath(): string | undefined { + if (process.platform === 'linux') { + return getLinuxDocumentsPath() + } else if (process.platform === 'darwin') { + return getMacOsDocumentsPath() + } else if (process.platform === 'win32') { + return getWindowsDocumentsPath() + } else { + return + } +} + +/** + * Returns the user documents path on Linux. + */ +function getLinuxDocumentsPath(): string { + const xdgDocumentsPath = getXdgDocumentsPath() + + return xdgDocumentsPath ?? path.join(os.homedir(), 'enso') +} + +/** + * Gets the documents directory from the XDG directory management system. + */ +function getXdgDocumentsPath(): string | undefined { + const out = childProcess.spawnSync('xdg-user-dir', ['DOCUMENTS'], { + timeout: CHILD_PROCESS_TIMEOUT, + }) + + if (out.error !== undefined) { + return + } else { + return out.stdout.toString().trim() + } +} + +/** + * Get the user documents path. On macOS, `Documents` acts as a symlink pointing to the + * real locale-specific user documents directory. + */ +function getMacOsDocumentsPath(): string { + return path.join(os.homedir(), 'Documents') +} + +/** + * Get the path to the `My Documents` Windows directory. + */ +function getWindowsDocumentsPath(): string | undefined { + const out = childProcess.spawnSync( + 'reg', + [ + 'query', + '"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\ShellFolders"', + '/v', + 'personal', + ], + { timeout: CHILD_PROCESS_TIMEOUT } + ) + + if (out.error !== undefined) { + return + } else { + const stdoutString = out.stdout.toString() + return stdoutString.split('\\s\\s+')[4] + } +} diff --git a/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts b/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts index 0cdd6f8eca0..52f184f78fb 100644 --- a/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts +++ b/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts @@ -17,6 +17,7 @@ import * as tar from 'tar' import * as common from 'enso-common' import * as buildUtils from 'enso-common/src/buildUtils' +import * as desktopEnvironment from './desktopEnvironment' const logger = console @@ -369,7 +370,12 @@ export function getProjectRoot(subtreePath: string): string | null { /** Get the directory that stores Enso projects. */ export function getProjectsDirectory(): string { - return pathModule.join(os.homedir(), 'enso', 'projects') + const documentsPath = desktopEnvironment.DOCUMENTS + if (documentsPath === undefined) { + return pathModule.join(os.homedir(), 'enso', 'projects') + } else { + return pathModule.join(documentsPath, 'enso-projects') + } } /** Check if the given project is installed, i.e. can be opened with the Project Manager. */ diff --git a/app/ide-desktop/lib/project-manager-shim/src/projectManagerShimMiddleware.ts b/app/ide-desktop/lib/project-manager-shim/src/projectManagerShimMiddleware.ts index 66e9e6d93a8..ce07aac80dc 100644 --- a/app/ide-desktop/lib/project-manager-shim/src/projectManagerShimMiddleware.ts +++ b/app/ide-desktop/lib/project-manager-shim/src/projectManagerShimMiddleware.ts @@ -3,7 +3,6 @@ import * as fs from 'node:fs/promises' import * as fsSync from 'node:fs' import * as http from 'node:http' -import * as os from 'node:os' import * as path from 'node:path' import * as isHiddenFile from 'is-hidden-file' @@ -22,7 +21,7 @@ import * as projectManagement from './projectManagement' const HTTP_STATUS_OK = 200 const HTTP_STATUS_BAD_REQUEST = 400 const HTTP_STATUS_NOT_FOUND = 404 -const PROJECTS_ROOT_DIRECTORY = path.join(os.homedir(), 'enso/projects') +const PROJECTS_ROOT_DIRECTORY = projectManagement.getProjectsDirectory() // ============= // === Types === diff --git a/build.sbt b/build.sbt index e9bcb8d1ed4..2aac1d07449 100644 --- a/build.sbt +++ b/build.sbt @@ -1120,6 +1120,7 @@ lazy val `project-manager` = (project in file("lib/scala/project-manager")) .value ) .dependsOn(`akka-native`) + .dependsOn(`desktop-environment`) .dependsOn(`version-output`) .dependsOn(editions) .dependsOn(`edition-updater`) @@ -2797,6 +2798,17 @@ lazy val `benchmarks-common` = ) .dependsOn(`polyglot-api`) +lazy val `desktop-environment` = + project + .in(file("lib/java/desktop-environment")) + .settings( + frgaalJavaCompilerSetting, + libraryDependencies ++= Seq( + "junit" % "junit" % junitVersion % Test, + "com.github.sbt" % "junit-interface" % junitIfVersion % Test + ) + ) + lazy val `bench-processor` = (project in file("lib/scala/bench-processor")) .settings( frgaalJavaCompilerSetting, diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Directories.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Directories.java new file mode 100644 index 00000000000..54ed82110cb --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Directories.java @@ -0,0 +1,21 @@ +package org.enso.desktopenvironment; + +import java.io.IOException; +import java.nio.file.Path; + +/** Provides information about user directories. */ +public sealed interface Directories permits LinuxDirectories, MacOsDirectories, WindowsDirectories { + + /** + * @return the user home directory. + */ + default Path getUserHome() { + return Path.of(System.getProperty("user.home")); + } + + /** + * @return the user documents directory. + * @throws IOException when cannot detect the documents directory of the user. + */ + Path getDocuments() throws IOException; +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/DirectoriesFactory.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/DirectoriesFactory.java new file mode 100644 index 00000000000..e6f37e9bece --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/DirectoriesFactory.java @@ -0,0 +1,28 @@ +package org.enso.desktopenvironment; + +final class DirectoriesFactory { + + private static final Directories INSTANCE = initDirectories(); + + private static Directories initDirectories() { + if (Platform.isLinux()) { + return new LinuxDirectories(); + } + + if (Platform.isMacOs()) { + return new MacOsDirectories(); + } + + if (Platform.isWindows()) { + return new WindowsDirectories(); + } + + throw new UnsupportedOperationException("Unsupported OS '" + Platform.getOsName() + "'"); + } + + private DirectoriesFactory() {} + + public static Directories getInstance() { + return INSTANCE; + } +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxDirectories.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxDirectories.java new file mode 100644 index 00000000000..c005bac8b35 --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxDirectories.java @@ -0,0 +1,38 @@ +package org.enso.desktopenvironment; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +final class LinuxDirectories implements Directories { + + private static final String[] PROCESS_XDG_DOCUMENTS = new String[] {"xdg-user-dir", "DOCUMENTS"}; + + /** + * Get the user 'Documents' directory. + * + *

Tries to obtain the documents directory from the XDG directory management system if + * available and falls back to {@code $HOME/enso}. + * + * @return the path to the user documents directory. + */ + @Override + public Path getDocuments() { + try { + return getXdgDocuments(); + } catch (IOException | InterruptedException e) { + return getUserHome().resolve("enso"); + } + } + + private Path getXdgDocuments() throws IOException, InterruptedException { + var process = new ProcessBuilder(PROCESS_XDG_DOCUMENTS).start(); + process.waitFor(3, TimeUnit.SECONDS); + + var documentsString = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + + return Path.of(documentsString.trim()); + } +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacOsDirectories.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacOsDirectories.java new file mode 100644 index 00000000000..dc689bb6e2a --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacOsDirectories.java @@ -0,0 +1,27 @@ +package org.enso.desktopenvironment; + +import java.io.IOException; +import java.nio.file.Path; + +final class MacOsDirectories implements Directories { + + private static final String DOCUMENTS = "Documents"; + + /** + * Get the user documents path. + * + *

On macOS, the 'Documents' directory acts like a symlink and points to the real + * locale-dependent user documents folder. + * + * @return the path to the user documents directory. + * @throws IOException when unable to resolve the real documents path. + */ + @Override + public Path getDocuments() throws IOException { + try { + return getUserHome().resolve(DOCUMENTS).toRealPath(); + } catch (IOException e) { + throw new IOException("Failed to resolve real MacOs documents path", e); + } + } +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Platform.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Platform.java new file mode 100644 index 00000000000..2bc20350dc8 --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Platform.java @@ -0,0 +1,31 @@ +package org.enso.desktopenvironment; + +public final class Platform { + + private static final String OS_NAME = "os.name"; + private static final String LINUX = "linux"; + private static final String MAC = "mac"; + private static final String WINDOWS = "windows"; + + private Platform() {} + + public static String getOsName() { + return System.getProperty(OS_NAME); + } + + public static boolean isLinux() { + return getOsName().toLowerCase().contains(LINUX); + } + + public static boolean isMacOs() { + return getOsName().toLowerCase().contains(MAC); + } + + public static boolean isWindows() { + return getOsName().toLowerCase().contains(WINDOWS); + } + + public static Directories getDirectories() { + return DirectoriesFactory.getInstance(); + } +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsDirectories.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsDirectories.java new file mode 100644 index 00000000000..195a2b7d0f7 --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsDirectories.java @@ -0,0 +1,47 @@ +package org.enso.desktopenvironment; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +final class WindowsDirectories implements Directories { + + private static final String[] PROCESS_REG_QUERY = + new String[] { + "reg", + "query", + "\"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\ShellFolders\"", + "/v", + "personal" + }; + + /** + * Get the path to 'My Documents' user directory. + * + *

Method uses the registry query that may not work on Windows XP versions and below. + * + * @return the 'My Documents' user directory path. + * @throws IOException when fails to detect the user documents directory. + */ + @Override + public Path getDocuments() throws IOException { + try { + var process = new ProcessBuilder(PROCESS_REG_QUERY).start(); + process.waitFor(3, TimeUnit.SECONDS); + + var stdoutString = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var stdoutParts = stdoutString.split("\\s\\s+"); + if (stdoutParts.length < 5) { + throw new IOException("Invalid Windows registry query output: '" + stdoutString + "'"); + } + + return Path.of(stdoutParts[4].trim()); + } catch (IOException e) { + throw new IOException("Failed to run Windows registry query", e); + } catch (InterruptedException e) { + throw new IOException("Windows registry query timeout", e); + } + } +} diff --git a/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/DirectoriesTest.java b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/DirectoriesTest.java new file mode 100644 index 00000000000..19810f8d65c --- /dev/null +++ b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/DirectoriesTest.java @@ -0,0 +1,30 @@ +package org.enso.desktopenvironment; + +import java.io.IOException; +import java.nio.file.Files; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +public class DirectoriesTest { + + private static Directories directories; + + @BeforeClass + public static void setup() { + directories = Platform.getDirectories(); + } + + @Test + public void getUserHome() { + var userHome = directories.getUserHome(); + Assert.assertTrue("User home is not a directory: " + userHome, Files.isDirectory(userHome)); + } + + @Test + public void getDocuments() throws IOException { + var documents = directories.getDocuments(); + Assert.assertTrue( + "User documents is not a directory" + documents, Files.isDirectory(documents)); + } +} diff --git a/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/PlatformTest.java b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/PlatformTest.java new file mode 100644 index 00000000000..1b8f7de1719 --- /dev/null +++ b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/PlatformTest.java @@ -0,0 +1,12 @@ +package org.enso.desktopenvironment; + +import org.junit.Assert; +import org.junit.Test; + +public class PlatformTest { + + @Test + public void getDirectories() { + Assert.assertNotNull(Platform.getDirectories()); + } +} diff --git a/lib/scala/project-manager/src/main/java/org/enso/projectmanager/event/package-info.java b/lib/scala/project-manager/src/main/java/org/enso/projectmanager/event/package-info.java deleted file mode 100644 index 3dcf3832573..00000000000 --- a/lib/scala/project-manager/src/main/java/org/enso/projectmanager/event/package-info.java +++ /dev/null @@ -1 +0,0 @@ -package org.enso.projectmanager.event; diff --git a/lib/scala/project-manager/src/main/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigration.java b/lib/scala/project-manager/src/main/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigration.java new file mode 100644 index 00000000000..b98d21a0bd7 --- /dev/null +++ b/lib/scala/project-manager/src/main/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigration.java @@ -0,0 +1,118 @@ +package org.enso.projectmanager.infrastructure.migration; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.util.HashSet; +import java.util.Set; +import org.apache.commons.io.FileUtils; +import org.enso.desktopenvironment.Platform; +import org.enso.projectmanager.boot.configuration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ProjectsMigration { + + private static final Logger logger = LoggerFactory.getLogger(ProjectsMigration.class); + + private ProjectsMigration() {} + + public static void migrate(configuration.StorageConfig storageConfig) { + var oldProjectsPath = + Platform.getDirectories().getUserHome().resolve("enso").resolve("projects").toFile(); + if (oldProjectsPath.isDirectory()) { + try { + File newProjectsPath = storageConfig.userProjectsPath(); + migrateProjectsDirectory(oldProjectsPath, newProjectsPath); + } catch (IOException e) { + logger.error("Migration aborted. Failed to get user documents directory.", e); + } + } + } + + static void migrateProjectsDirectory(File oldProjectsPath, File newProjectsPath) { + if (oldProjectsPath.isDirectory()) { + logger.info( + "Running projects migration from '{}' to '{}'.", oldProjectsPath, newProjectsPath); + if (newProjectsPath.isDirectory()) { + try { + logger.info( + "Both '{}' and '{}' project directories exist. Cleaning up.", + oldProjectsPath, + newProjectsPath); + FileUtils.deleteDirectory(oldProjectsPath); + return; + } catch (IOException e) { + logger.error( + "Both '{}' and '{}' project directories exist. Failed to clean up.", + oldProjectsPath, + newProjectsPath, + e); + return; + } + } + + try { + logger.info( + "Moving projects directory from '{}' to '{}'.", oldProjectsPath, newProjectsPath); + moveDirectory(oldProjectsPath, newProjectsPath); + } catch (IOException ex) { + logger.error("Migration aborted. Failed to copy user projects directory.", ex); + return; + } + + if (!Platform.isWindows()) { + try { + logger.info("Setting projects directory permissions '{}'.", newProjectsPath); + setProjectsDirectoryPermissions(newProjectsPath); + } catch (IOException ex) { + logger.error( + "Failed to set permissions on projects directory '{}'.", newProjectsPath, ex); + return; + } + } + + logger.info("Projects migration successful."); + } + } + + /** + * Moves the source directory to the destination directory by renaming the source directory. If + * renaming is not supported by the file system or the destination directory is located on a + * different file system, tries to copy the source directory and then delete the source. + * + * @param source the source path + * @param destination the destination path + * @throws IOException if the moving was unsuccessful + */ + static void moveDirectory(File source, File destination) throws IOException { + try { + Files.move(source.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + try { + FileUtils.copyDirectory(source, destination); + } catch (IOException ex) { + FileUtils.deleteQuietly(destination); + throw ex; + } + FileUtils.deleteDirectory(source); + } + } + + /** + * Sets the projects directory permissions to {@code rwx------}. + * + * @param path the directory path. + * @throws IOException if the action is unsuccessful. + */ + static void setProjectsDirectoryPermissions(File path) throws IOException { + Set permissions = new HashSet<>(); + permissions.add(PosixFilePermission.OWNER_READ); + permissions.add(PosixFilePermission.OWNER_WRITE); + permissions.add(PosixFilePermission.OWNER_EXECUTE); + + Files.setPosixFilePermissions(path.toPath(), permissions); + } +} diff --git a/lib/scala/project-manager/src/main/resources/application.conf b/lib/scala/project-manager/src/main/resources/application.conf index f284777aa64..368c88996ca 100644 --- a/lib/scala/project-manager/src/main/resources/application.conf +++ b/lib/scala/project-manager/src/main/resources/application.conf @@ -119,14 +119,12 @@ project-manager { } storage { - projects-root = ${user.home}/enso projects-root = ${?PROJECTS_ROOT} - temporary-projects-path = ${project-manager.storage.projects-root}/tmp - user-projects-path = ${project-manager.storage.projects-root}/projects - tutorials-path = ${project-manager.storage.projects-root}/tutorials - tutorials-cache-path = ${project-manager.storage.projects-root}/.tutorials-cache - project-metadata-directory = ".enso" - project-metadata-file-name = "project.json" + projects-directory = "enso-projects" + metadata { + project-metadata-directory = ".enso" + project-metadata-file-name = "project.json" + } } timeout { diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/MainModule.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/MainModule.scala index f612851790b..99a844cbc60 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/MainModule.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/MainModule.scala @@ -21,10 +21,7 @@ import org.enso.projectmanager.infrastructure.languageserver.{ } import org.enso.projectmanager.infrastructure.log.Slf4jLogging import org.enso.projectmanager.infrastructure.random.SystemGenerator -import org.enso.projectmanager.infrastructure.repository.{ - ProjectFileRepository, - ProjectFileRepositoryFactory -} +import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory import org.enso.projectmanager.infrastructure.time.RealClock import org.enso.projectmanager.protocol.{ JsonRpcProtocolFactory, @@ -74,14 +71,6 @@ class MainModule[ lazy val projectRepositoryFactory = new ProjectFileRepositoryFactory[F](config.storage, clock, fileSystem, gen) - lazy val projectRepository = - new ProjectFileRepository[F]( - config.storage, - clock, - fileSystem, - gen - ) - val distributionConfiguration = DefaultDistributionConfiguration val loggingService = Logging.GlobalLoggingService @@ -149,7 +138,7 @@ class MainModule[ timeoutConfig = config.timeout ) - lazy val projectsEndpoint = new ProjectsEndpoint(projectRepository) + lazy val projectsEndpoint = new ProjectsEndpoint(projectRepositoryFactory) lazy val server = new JsonRpcServer( new JsonRpcProtocolFactory, diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/ProjectManager.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/ProjectManager.scala index 79a1be76ee9..f132d2d7a1e 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/ProjectManager.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/ProjectManager.scala @@ -21,6 +21,7 @@ import org.enso.projectmanager.boot.configuration.{ MainProcessConfig, ProjectManagerConfig } +import org.enso.projectmanager.infrastructure.migration.ProjectsMigration import org.enso.projectmanager.protocol.JsonRpcProtocolFactory import org.enso.version.VersionDescription import org.slf4j.event.Level @@ -53,11 +54,11 @@ object ProjectManager extends ZIOAppDefault with LazyLogging { new JsonRpcProtocolFactory().getProtocol() ) - val computeThreadPool = new ScheduledThreadPoolExecutor( + private val computeThreadPool = new ScheduledThreadPoolExecutor( java.lang.Runtime.getRuntime.availableProcessors() ) - val computeExecutionContext: ExecutionContextExecutor = + private val computeExecutionContext: ExecutionContextExecutor = ExecutionContext.fromExecutor( computeThreadPool, th => logger.error("An expected error occurred.", th) @@ -77,6 +78,7 @@ object ProjectManager extends ZIOAppDefault with LazyLogging { private def mainProcess( processConfig: MainProcessConfig ): ZIO[ZAny, IOException, Unit] = { + ProjectsMigration.migrate(config.storage) val mainModule = new MainModule[ZIO[ZAny, +*, +*]]( config, diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/ProjectListCommand.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/ProjectListCommand.scala index ed8c8b006bd..92afe861541 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/ProjectListCommand.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/ProjectListCommand.scala @@ -8,7 +8,7 @@ import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.repository.{ - ProjectFileRepository, + ProjectFileRepositoryFactory, ProjectRepository } import org.enso.projectmanager.infrastructure.time.RealClock @@ -47,17 +47,15 @@ object ProjectListCommand { val clock = new RealClock[F] val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val gen = new SystemGenerator[F] - val storageConfig = projectsPath.fold(config.storage)(path => - config.storage.copy(userProjectsPath = path) - ) + val projectRepositoryFactory = new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen + ) val projectRepository = - new ProjectFileRepository[F]( - storageConfig, - clock, - fileSystem, - gen - ) + projectRepositoryFactory.getProjectRepository(projectsPath) new ProjectListCommand[F](projectRepository, limitOpt) } diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/configuration.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/configuration.scala index b60327e28b9..af220fd6481 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/configuration.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/configuration.scala @@ -1,9 +1,11 @@ package org.enso.projectmanager.boot +import org.enso.desktopenvironment.Platform import org.slf4j.event.Level -import java.io.File +import java.io.{File, IOException} import java.nio.file.Path + import scala.concurrent.duration.FiniteDuration object configuration { @@ -41,21 +43,37 @@ object configuration { */ case class ServerConfig(host: String, port: Int) - /** A configuration object for properties of project storage. + /** A configuration object for metadata storage. * - * @param projectsRoot a project root - * @param userProjectsPath a user project root - * @param projectMetadataDirectory a directory name containing project - * metadata + * @param projectMetadataDirectory a directory name containing project metadata * @param projectMetadataFileName a name of project metadata file */ - case class StorageConfig( - projectsRoot: File, - userProjectsPath: File, + case class MetadataStorageConfig( projectMetadataDirectory: String, projectMetadataFileName: String ) + /** A configuration object for properties of project storage. + * + * @param projectsRoot overrides user projects root directory + * @param projectsDirectory a user projects directory + * @param metadata a metadata storage config + */ + case class StorageConfig( + projectsRoot: Option[File], + projectsDirectory: String, + metadata: MetadataStorageConfig + ) { + + /** @return a path to the user projects directory. */ + @throws[IOException] + def userProjectsPath: File = { + val projectsRootDirectory = + projectsRoot.getOrElse(Platform.getDirectories.getDocuments.toFile) + new File(projectsRootDirectory, projectsDirectory) + } + } + /** A configuration object for timeout properties. * * @param ioTimeout a timeout for IO operations diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/http/ProjectsEndpoint.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/http/ProjectsEndpoint.scala index dd8e82fc392..920050a1a58 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/http/ProjectsEndpoint.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/http/ProjectsEndpoint.scala @@ -15,7 +15,7 @@ import org.enso.projectmanager.control.core.CovariantFlatMap import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.effect.Exec import org.enso.projectmanager.infrastructure.repository.{ - ProjectRepository, + ProjectRepositoryFactory, ProjectRepositoryFailure } @@ -26,10 +26,13 @@ import scala.util.{Failure, Success} final class ProjectsEndpoint[ F[+_, +_]: Exec: CovariantFlatMap -](repo: ProjectRepository[F]) +](projectRepositoryFactory: ProjectRepositoryFactory[F]) extends Endpoint with LazyLogging { + private val projectRepository = + projectRepositoryFactory.getProjectRepository(None) + /** @inheritdoc */ override def route: Route = projectsEndpoint @@ -74,7 +77,7 @@ final class ProjectsEndpoint[ projectId: UUID ): Future[Either[ProjectRepositoryFailure, Option[EnsoProjectArchive]]] = Exec[F].exec { - repo + projectRepository .findById(projectId) .map(projectOpt => projectOpt.map(project => diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/MetadataFileStorage.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/MetadataFileStorage.scala index fd3c1a0105b..7c767ea81a8 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/MetadataFileStorage.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/MetadataFileStorage.scala @@ -1,9 +1,8 @@ package org.enso.projectmanager.infrastructure.repository import java.io.File - import io.circe.generic.auto._ -import org.enso.projectmanager.boot.configuration.StorageConfig +import org.enso.projectmanager.boot.configuration.MetadataStorageConfig import org.enso.projectmanager.control.core.CovariantFlatMap import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.effect.syntax._ @@ -27,7 +26,7 @@ import shapeless.{Coproduct, Inl, Inr} /** File based implementation of the project metadata storage. * * @param directory a project directory - * @param storageConfig a storage config + * @param metadataStorageConfig a metadata storage config * @param clock a clock * @param fileSystem a file system abstraction * @param gen a random generator @@ -36,7 +35,7 @@ final class MetadataFileStorage[ F[+_, +_]: ErrorChannel: CovariantFlatMap ]( directory: File, - storageConfig: StorageConfig, + metadataStorageConfig: MetadataStorageConfig, clock: Clock[F], fileSystem: FileSystem[F], gen: Generator[F] @@ -89,8 +88,8 @@ final class MetadataFileStorage[ new File( project, new File( - storageConfig.projectMetadataDirectory, - storageConfig.projectMetadataFileName + metadataStorageConfig.projectMetadataDirectory, + metadataStorageConfig.projectMetadataFileName ).toString ) } diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala index e3a79c2184b..08b5bb934bc 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala @@ -4,9 +4,8 @@ import java.io.File import java.nio.file.Path import java.nio.file.attribute.FileTime import java.util.UUID - import org.enso.pkg.{Package, PackageManager} -import org.enso.projectmanager.boot.configuration.StorageConfig +import org.enso.projectmanager.boot.configuration.MetadataStorageConfig import org.enso.projectmanager.control.core.{ Applicative, CovariantFlatMap, @@ -31,7 +30,7 @@ import org.enso.projectmanager.model.{Project, ProjectMetadata} /** File based implementation of the project repository. * - * @param storageConfig a storage config + * @param metadataStorageConfig a metadata storage config * @param clock a clock * @param fileSystem a file system abstraction * @param gen a random generator @@ -39,7 +38,8 @@ import org.enso.projectmanager.model.{Project, ProjectMetadata} class ProjectFileRepository[ F[+_, +_]: Sync: ErrorChannel: CovariantFlatMap: Applicative ]( - storageConfig: StorageConfig, + projectsPath: File, + metadataStorageConfig: MetadataStorageConfig, clock: Clock[F], fileSystem: FileSystem[F], gen: Generator[F] @@ -60,7 +60,7 @@ class ProjectFileRepository[ /** @inheritdoc */ override def getAll(): F[ProjectRepositoryFailure, List[Project]] = { fileSystem - .list(storageConfig.userProjectsPath) + .list(projectsPath) .map(_.filter(_.isDirectory)) .recover { case FileNotFound | NotDirectory => Nil @@ -243,7 +243,7 @@ class ProjectFileRepository[ for { project <- getProject(projectId) - primaryPath = new File(storageConfig.userProjectsPath, newName) + primaryPath = new File(projectsPath, newName) finalPath <- if (isLocationOk(project.path, primaryPath)) { CovariantFlatMap[F].pure(primaryPath) @@ -283,7 +283,7 @@ class ProjectFileRepository[ .tailRecM[ProjectRepositoryFailure, Int, File](0) { number => val path = new File( - storageConfig.userProjectsPath, + projectsPath, moduleName + genSuffix(number) ) fileSystem @@ -307,7 +307,7 @@ class ProjectFileRepository[ private def metadataStorage(projectPath: File): MetadataFileStorage[F] = new MetadataFileStorage[F]( projectPath, - storageConfig, + metadataStorageConfig, clock, fileSystem, gen diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepositoryFactory.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepositoryFactory.scala index e3d764373b1..d29f5610561 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepositoryFactory.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepositoryFactory.scala @@ -21,9 +21,14 @@ class ProjectFileRepositoryFactory[ override def getProjectRepository( projectsDirectory: Option[File] ): ProjectRepository[F] = { - val config = projectsDirectory.fold(storageConfig)(dir => - storageConfig.copy(userProjectsPath = dir) + val projectsPath = + projectsDirectory.getOrElse(storageConfig.userProjectsPath) + new ProjectFileRepository[F]( + projectsPath, + storageConfig.metadata, + clock, + fileSystem, + gen ) - new ProjectFileRepository[F](config, clock, fileSystem, gen) } } diff --git a/lib/scala/project-manager/src/test/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigrationTest.java b/lib/scala/project-manager/src/test/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigrationTest.java new file mode 100644 index 00000000000..02d1651c2f9 --- /dev/null +++ b/lib/scala/project-manager/src/test/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigrationTest.java @@ -0,0 +1,124 @@ +package org.enso.projectmanager.infrastructure.migration; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermissions; +import org.apache.commons.io.FileUtils; +import org.enso.desktopenvironment.Platform; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ProjectsMigrationTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void moveDirectory() throws IOException { + File oldProjectsDir = tmp.newFolder("old-projects"); + File newProjectsDir = new File(tmp.getRoot(), "new-projects"); + + File project1 = createProjectStructure(oldProjectsDir, "Project1"); + File project2 = createProjectStructure(oldProjectsDir, "Project2"); + + Assert.assertTrue(oldProjectsDir.isDirectory()); + Assert.assertTrue(project1.isDirectory()); + Assert.assertTrue(project2.isDirectory()); + Assert.assertFalse(newProjectsDir.isDirectory()); + + ProjectsMigration.moveDirectory(oldProjectsDir, newProjectsDir); + + Assert.assertFalse(oldProjectsDir.isDirectory()); + Assert.assertTrue(newProjectsDir.isDirectory()); + Assert.assertTrue(new File(newProjectsDir, "Project1").isDirectory()); + Assert.assertTrue(new File(newProjectsDir, "Project2").isDirectory()); + } + + @Test + public void setProjectDirectoryPermissions() throws IOException { + if (!Platform.isWindows()) { + File projectsDir = tmp.newFolder("projects"); + createProjectStructure(projectsDir, "Project1"); + + Assert.assertTrue(projectsDir.isDirectory()); + + ProjectsMigration.setProjectsDirectoryPermissions(projectsDir); + + var permissions = Files.getPosixFilePermissions(projectsDir.toPath()); + Assert.assertEquals("rwx------", PosixFilePermissions.toString(permissions)); + } + } + + @Test + public void migrateProjectsDirectoryIdempotent() throws IOException { + File oldProjectsDir = tmp.newFolder("old-projects"); + File newProjectsDir = new File(tmp.getRoot(), "new-projects"); + + File project1 = createProjectStructure(oldProjectsDir, "Project1"); + File project2 = createProjectStructure(oldProjectsDir, "Project2"); + + Assert.assertTrue(oldProjectsDir.isDirectory()); + Assert.assertTrue(project1.isDirectory()); + Assert.assertTrue(project2.isDirectory()); + Assert.assertFalse(newProjectsDir.isDirectory()); + + ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir); + + File newProject1 = new File(newProjectsDir, "Project1"); + File newProject2 = new File(newProjectsDir, "Project2"); + Assert.assertFalse(oldProjectsDir.isDirectory()); + Assert.assertTrue(newProjectsDir.isDirectory()); + Assert.assertTrue(newProject1.isDirectory()); + Assert.assertTrue(newProject2.isDirectory()); + + ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir); + + Assert.assertFalse(oldProjectsDir.isDirectory()); + Assert.assertTrue(newProjectsDir.isDirectory()); + Assert.assertTrue(newProject1.isDirectory()); + Assert.assertTrue(newProject2.isDirectory()); + } + + @Test + public void migrateProjectsDirectoryCleanupWhenBothExist() throws IOException { + File oldProjectsDir = tmp.newFolder("old-projects"); + File newProjectsDir = new File(tmp.getRoot(), "new-projects"); + + File project1 = createProjectStructure(newProjectsDir, "Project1"); + File project2 = createProjectStructure(newProjectsDir, "Project2"); + + Assert.assertTrue(oldProjectsDir.isDirectory()); + Assert.assertTrue(newProjectsDir.isDirectory()); + Assert.assertTrue(project1.isDirectory()); + Assert.assertTrue(project2.isDirectory()); + + ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir); + + Assert.assertFalse(oldProjectsDir.isDirectory()); + Assert.assertTrue(newProjectsDir.isDirectory()); + Assert.assertTrue(new File(newProjectsDir, "Project1").isDirectory()); + Assert.assertTrue(new File(newProjectsDir, "Project2").isDirectory()); + } + + private static File createProjectStructure(File tmp, String name) throws IOException { + var projectDir = new File(tmp, name); + var srcDir = new File(projectDir, "src"); + var ensoDir = new File(projectDir, ".enso"); + + FileUtils.forceMkdir(srcDir); + FileUtils.forceMkdir(ensoDir); + + createNewFile(new File(projectDir, "package.yaml")); + createNewFile(new File(srcDir, "Main.enso")); + + return projectDir; + } + + private static void createNewFile(File file) throws IOException { + if (!file.createNewFile()) { + throw new IOException("File '" + file + "' already exists."); + } + } +} diff --git a/lib/scala/project-manager/src/test/resources/application-test.conf b/lib/scala/project-manager/src/test/resources/application-test.conf index 688e6b1c3b0..fe2ecb616f9 100644 --- a/lib/scala/project-manager/src/test/resources/application-test.conf +++ b/lib/scala/project-manager/src/test/resources/application-test.conf @@ -56,13 +56,12 @@ project-manager { } storage { - projects-root = ${user.home}/enso - projects-root=${?PROJECTS_ROOT} - project-index-path = ${project-manager.storage.projects-root}/.enso/project-index.json - temporary-projects-path = ${project-manager.storage.projects-root}/tmp - user-projects-path = ${project-manager.storage.projects-root}/projects - tutorials-path = ${project-manager.storage.projects-root}/tutorials - tutorials-cache-path = ${project-manager.storage.projects-root}/.tutorials-cache + projects-root = ${?PROJECTS_ROOT} + projects-directory = "enso-projects" + metadata { + project-metadata-directory = ".enso" + project-metadata-file-name = "project.json" + } } timeout { diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/BaseServerSpec.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/BaseServerSpec.scala index 1332a1018b9..277abb66ad4 100644 --- a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/BaseServerSpec.scala +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/BaseServerSpec.scala @@ -108,15 +108,17 @@ class BaseServerSpec extends JsonRpcServerTestKit with BeforeAndAfterAll { val testDistributionRoot = Files.createTempDirectory(null).toFile sys.addShutdownHook(FileUtils.deleteQuietly(testDistributionRoot)) - val userProjectDir = new File(testProjectsRoot, "projects") - lazy val testStorageConfig = StorageConfig( - projectsRoot = testProjectsRoot, - userProjectsPath = userProjectDir, - projectMetadataDirectory = ".enso", - projectMetadataFileName = "project.json" + projectsRoot = Some(testProjectsRoot), + projectsDirectory = "enso-projects", + metadata = MetadataStorageConfig( + projectMetadataDirectory = ".enso", + projectMetadataFileName = "project.json" + ) ) + lazy val userProjectDir = testStorageConfig.userProjectsPath + lazy val bootloaderConfig = config.bootloader lazy val timeoutConfig = config.timeout diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala index 281a9ee5b1e..8c0d67a6a85 100644 --- a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala @@ -46,7 +46,7 @@ class FileSystemServiceSpec def metadataFileStorage(directory: File) = new MetadataFileStorage[ZIO[ZAny, +*, +*]]( directory, - config.storage, + config.storage.metadata, testClock, fileSystem, gen