Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only register mailto handler on Windows for current user #3814

Merged
merged 2 commits into from
Jan 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
134 changes: 30 additions & 104 deletions src/desktop/DesktopUtils.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import path from "path"
import {exec, spawn} from "child_process"
import {promisify} from "util"
import {spawn} from "child_process"
import type {Rectangle} from "electron"
import {app} from "electron"
import {defer, delay, noOp, uint8ArrayToHex} from "@tutao/tutanota-utils"
import {log} from "./DesktopLog"
import {DesktopCryptoFacade} from "./DesktopCryptoFacade"
import {fileExists, swapFilename} from "./PathUtils"
import url from "url"
import {registerKeys, unregisterKeys} from "./reg-templater"
import {makeRegisterKeysScript, makeUnregisterKeysScript, RegistryRoot} from "./reg-templater"
import type {ElectronExports, FsExports} from "./ElectronExportTypes";
import {DataFile} from "../api/common/DataFile";
import {ProgrammingError} from "../api/common/error/ProgrammingError"

export class DesktopUtils {
readonly _fs: FsExports
readonly _electron: ElectronExports
readonly _desktopCrypto: DesktopCryptoFacade
readonly _topLevelDownloadDir: string = "tutanota"
private readonly _fs: FsExports
private readonly _electron: ElectronExports
private readonly _desktopCrypto: DesktopCryptoFacade
private readonly _topLevelDownloadDir: string = "tutanota"

constructor(fs: FsExports, electron: ElectronExports, desktopCrypto: DesktopCryptoFacade) {
this._fs = fs
Expand Down Expand Up @@ -75,53 +74,37 @@ export class DesktopUtils {

switch (process.platform) {
case "win32":
const isLocal = await this.checkIsPerUserInstall()
if (isLocal) {
await this.doRegisterMailtoOnWin32WithCurrentUser()
} else {
const isAdmin = await checkForAdminStatus()
if (!isAdmin) {
// We require admin rights in windows, so we will recursively run the tutanota client with admin privileges
// and then call doRegisterMailtoOnWin32WithCurrentUser() from that process
await elevatePermissions(["-r"])
} else {
await this.doRegisterMailtoOnWin32WithCurrentUser()
}
}
await this.doRegisterMailtoOnWin32WithCurrentUser()
break
case "darwin":
const didRegister = app.setAsDefaultProtocolClient("mailto")
if (!didRegister) {
throw new Error("Could not register as mailto handler")
}
break
case "linux":
return app.setAsDefaultProtocolClient("mailto")
? Promise.resolve()
: Promise.reject()
throw new Error("Registering protocols on Linux does not work")
default:
return Promise.reject(new Error("Invalid process.platform"))
throw new Error(`Invalid process.platform: ${process.platform}`)
}
}

async unregisterAsMailtoHandler(): Promise<void> {
log.debug("trying to unregister mailto...")
switch (process.platform) {
case "win32":
const isLocal = await this.checkIsPerUserInstall()
if (isLocal) {
await this.doUnregisterMailtoOnWin32WithCurrentUser()
} else {
const isAdmin = await checkForAdminStatus()
if (!isAdmin) {
await elevatePermissions(["-u"])
} else {
await this.doUnregisterMailtoOnWin32WithCurrentUser()
}
}
await this.doUnregisterMailtoOnWin32WithCurrentUser()
break
case "darwin":
const didUnregister = app.removeAsDefaultProtocolClient("mailto")
if (!didUnregister) {
throw new Error("Could not unregister as mailto handler")
}
break
case "linux":
return app.removeAsDefaultProtocolClient("mailto")
? Promise.resolve()
: Promise.reject()
throw new Error("Registering protocols on Linux does not work")
default:
return Promise.reject(new Error(`invalid platform: ${process.platform}`))
throw new Error(`Invalid process.platform: ${process.platform}`)
}
}

Expand Down Expand Up @@ -197,10 +180,10 @@ export class DesktopUtils {
* @param script: source of the registry script
* @private
*/
_executeRegistryScript(script: string): Promise<void> {
private async _executeRegistryScript(script: string): Promise<void> {
const deferred = defer<void>()

const file = this._writeToDisk(script)
const file = await this._writeToDisk(script)

spawn("reg.exe", ["import", file], {
stdio: ["ignore", "inherit", "inherit"],
Expand All @@ -220,29 +203,20 @@ export class DesktopUtils {
/**
* Writes contents with a random file name into the directory of the executable
* @param contents
* @returns {string} path to the written file
* @private
* @returns path to the written file
*/
_writeToDisk(contents: string): string {
private async _writeToDisk(contents: string): Promise<string> {
const filename = uint8ArrayToHex(this._desktopCrypto.randomBytes(12))
const filePath = swapFilename(process.execPath, filename)

this._fs.writeFileSync(filePath, contents, {
await this._fs.promises.writeFile(filePath, contents, {
encoding: "utf-8",
mode: 0o400,
})

return filePath
}

readJSONSync(absolutePath: string): Record<string, unknown> {
return JSON.parse(
this._fs.readFileSync(absolutePath, {
encoding: "utf8",
}),
)
}

async doRegisterMailtoOnWin32WithCurrentUser(): Promise<void> {
if (process.platform !== "win32") {
throw new ProgrammingError("Not win32")
Expand All @@ -260,16 +234,7 @@ export class DesktopUtils {
const appData = path.join("%USERPROFILE%", "AppData")
const logPath = path.join(appData, "Roaming", app.getName(), "logs")
const tmpPath = path.join(appData, "Local", "Temp", this._topLevelDownloadDir, "attach")
const isLocal = await this.checkIsPerUserInstall()
const tmpRegScript = registerKeys(
{
execPath,
dllPath,
logPath,
tmpPath,
},
isLocal,
)
const tmpRegScript = makeRegisterKeysScript(RegistryRoot.CURRENT_USER, {execPath, dllPath, logPath, tmpPath})
await this._executeRegistryScript(tmpRegScript)
app.setAsDefaultProtocolClient("mailto")
await this._openDefaultAppsSettings()
Expand All @@ -280,13 +245,12 @@ export class DesktopUtils {
throw new ProgrammingError("Not win32")
}
app.removeAsDefaultProtocolClient('mailto')
const isLocal = await this.checkIsPerUserInstall()
const tmpRegScript = unregisterKeys(isLocal)
const tmpRegScript = makeUnregisterKeysScript(RegistryRoot.CURRENT_USER)
await this._executeRegistryScript(tmpRegScript)
await this._openDefaultAppsSettings()
}

async _openDefaultAppsSettings(): Promise<void> {
private async _openDefaultAppsSettings(): Promise<void> {
try {
await this._electron.shell.openExternal("ms-settings:defaultapps")
} catch (e) {
Expand All @@ -305,50 +269,12 @@ export class DesktopUtils {
}
}

/**
* Checks if the user has admin privileges
* @returns {Promise<boolean>} true if user has admin privileges
*/
function checkForAdminStatus(): Promise<boolean> {
if (process.platform === "win32") {
return promisify(exec)("NET SESSION")
.then(() => true)
.catch(() => false)
} else {
return Promise.reject(new Error(`No NET SESSION on ${process.platform}`))
}
}

function getLockFilePath() {
// don't get temp dir path from DesktopDownloadManager because the path returned from there may be deleted at some point,
// we want to put the lockfile in root tmp so it persists
return path.join(app.getPath("temp"), "tutanota_desktop_lockfile")
}

/**
* Uses the bundled elevate.exe to show a UAC dialog to the user and execute command with elevated permissions.
* @private
*/
function elevatePermissions(args: Array<string>) {
if (process.platform !== "win32") {
throw new ProgrammingError("Trying to elevate permissions but not on win32")
}
const deferred = defer()
const elevateExe = path.join((process as any).resourcesPath, "elevate.exe")
let elevateArgs = ["-wait", process.execPath].concat(args)
spawn(elevateExe, elevateArgs, {
stdio: ["ignore", "inherit", "inherit"],
detached: false,
}).on("exit", (code, signal) => {
if (code === 0) {
deferred.resolve(undefined)
} else {
deferred.reject(new Error("couldn't elevate permissions"))
}
})
return deferred.promise
}

export function isRectContainedInRect(closestRect: Rectangle, lastBounds: Rectangle): boolean {
return (
lastBounds.x >= closestRect.x - 10 &&
Expand Down
58 changes: 36 additions & 22 deletions src/desktop/reg-templater.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import type {RegistryTemplateDefinition} from "./integration/RegistryScriptGenerator"
import {applyScriptBuilder, removeScriptBuilder} from "./integration/RegistryScriptGenerator"

const esc = (s: string) => s.replace(/\\/g, "\\\\")
export enum RegistryRoot {
/** Global (per-device) registry keys */
LOCAL_MACHINE = "HKEY_LOCAL_MACHINE",
/** Per-user registry keys */
CURRENT_USER = "HKEY_CURRENT_USER",
}

const hklm = "HKEY_LOCAL_MACHINE"
const hkcu = "HKEY_CURRENT_USER"
const hkcr = "HKEY_CLASSES_ROOT"
/**
* > The HKEY_CLASSES_ROOT (HKCR) key contains file name extension associations and COM class registration information such as ProgIDs, CLSIDs, and IIDs.
* > It is primarily intended for compatibility with the registry in 16-bit Windows.
*
* see https://docs.microsoft.com/en-us/windows/win32/sysinfo/hkey-classes-root-key
*/
const HKCR = "HKEY_CLASSES_ROOT"

/**
* execPath: path for the dll to this executable
Expand All @@ -24,7 +33,7 @@ export type RegistryPaths = {
* get a registry template specific to tutanota desktop
* https://docs.microsoft.com/en-us/windows/win32/msi/installation-context#registry-redirection
*/
function getTemplate(opts: RegistryPaths, local: boolean): RegistryTemplateDefinition {
function getTemplate(opts: RegistryPaths, registryRoot: RegistryRoot): RegistryTemplateDefinition {
const {execPath, dllPath, logPath, tmpPath} = opts
const client_template = {
tutanota: {
Expand Down Expand Up @@ -61,65 +70,70 @@ function getTemplate(opts: RegistryPaths, local: boolean): RegistryTemplateDefin
},
},
}
const r = local ? hkcu : hklm

return [
{
root: `${r}\\SOFTWARE\\Clients\\Mail`,
root: `${registryRoot}\\SOFTWARE\\Clients\\Mail`,
value: client_template,
},
{
root: `${r}\\SOFTWARE\\CLASSES`,
root: `${registryRoot}\\SOFTWARE\\CLASSES`,
value: {
mailto: mailto_template,
"tutanota.Mailto": mailto_template,
},
},
{
root: hkcr,
root: HKCR,
value: {
mailto: mailto_template,
"tutanota.Mailto": mailto_template,
},
},
{
root: `${r}\\SOFTWARE\\RegisteredApplications`,
root: `${registryRoot}\\SOFTWARE\\RegisteredApplications`,
value: {
tutanota: "SOFTWARE\\\\tutao\\\\tutanota\\\\Capabilities",
},
},
{
root: `${r}\\SOFTWARE\\Wow6432Node\\RegisteredApplications`,
root: `${registryRoot}\\SOFTWARE\\Wow6432Node\\RegisteredApplications`,
value: {
tutanota: "SOFTWARE\\\\Wow6432Node\\\\tutao\\\\tutanota\\\\Capabilities",
},
},
{
root: `${r}\\SOFTWARE`,
root: `${registryRoot}\\SOFTWARE`,
value: capabilities_template,
},
{
root: `${r}\\SOFTWARE\\Wow6432Node`,
root: `${registryRoot}\\SOFTWARE\\Wow6432Node`,
value: capabilities_template,
},
]
}

function escape(s: string): string {
return s.replace(/\\/g, "\\\\")
}


/**
* produce a tmp windows registry script to register an executable as a mailto handler
* @param registryRoot
* @param opts {RegistryPaths}
* @param local set to true if the app was installed per-user
* @returns {string} registry script
*/
export function registerKeys(opts: RegistryPaths, local: boolean): string {
export function makeRegisterKeysScript(registryRoot: RegistryRoot, opts: RegistryPaths): string {
const {execPath, dllPath, logPath, tmpPath} = opts
const template = getTemplate(
{
execPath: esc(execPath),
dllPath: esc(dllPath),
logPath: esc(logPath),
tmpPath: esc(tmpPath),
execPath: escape(execPath),
dllPath: escape(dllPath),
logPath: escape(logPath),
tmpPath: escape(tmpPath),
},
local,
registryRoot,
)
return applyScriptBuilder(template)
}
Expand All @@ -128,7 +142,7 @@ export function registerKeys(opts: RegistryPaths, local: boolean): string {
* produce a tmp windows registry script to unregister tutanota as a mailto handler
* @returns {string} registry script
*/
export function unregisterKeys(local: boolean): string {
export function makeUnregisterKeysScript(registryRoot: RegistryRoot): string {
// the removal script generator doesn't care about values
const template = getTemplate(
{
Expand All @@ -137,7 +151,7 @@ export function unregisterKeys(local: boolean): string {
logPath: "logPath",
tmpPath: "tmpPath",
},
local,
registryRoot,
)
return removeScriptBuilder(template)
}