Skip to content

Commit

Permalink
Detect Flatpak permission errors on drag and drop (#1004)
Browse files Browse the repository at this point in the history
  • Loading branch information
GarboMuffin authored May 28, 2024
1 parent a5a9447 commit 79e8db5
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 0 deletions.
12 changes: 12 additions & 0 deletions src-main/l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -458,5 +458,17 @@
"rich-presence.untitled": {
"string": "Untitled Project",
"developer_comment": "Appears in Discord rich presence when the current project doesn't have a name."
},
"file-access.window-title": {
"string": "Can't Access Files",
"developer_comment": "Title of window that appears when user tries to drag & drop a file that the app can't access because of a security sandbox."
},
"file-access.flatpak": {
"string": "The Flatpak sandbox may be preventing {APP_NAME} from accessing files that you tried to drag and drop:",
"developer_comment": "Part of window that appears when user tries to drag & drop a file that the app can't access. Followed by list of paths. Leave 'Flatpak' in English."
},
"file-access.how-to-fix": {
"string": "Use the in-app file picker instead, or run the following command in a terminal and restart the app to grant access to the files:",
"developer_comment": "Part of window that appears when user tries to drag & drop a file that the app can't access."
}
}
3 changes: 3 additions & 0 deletions src-main/protocols.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ const FILE_SCHEMES = {
},
'tw-security-prompt': {
root: path.resolve(__dirname, '../src-renderer/security-prompt'),
},
'tw-file-access': {
root: path.resolve(__dirname, '../src-renderer/file-access'),
}
};

Expand Down
5 changes: 5 additions & 0 deletions src-main/windows/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const prompts = require('../prompts');
const settings = require('../settings');
const privilegedFetch = require('../fetch');
const RichPresence = require('../rich-presence.js');
const FileAccessWindow = require('./file-access-window.js');

const TYPE_FILE = 'file';
const TYPE_URL = 'url';
Expand Down Expand Up @@ -508,6 +509,10 @@ class EditorWindow extends ProjectRunningWindow {
};
});

ipc.handle('check-drag-and-drop-path', (event, filePath) => {
FileAccessWindow.check(filePath);
});

/**
* Refers to the full screen button in the editor, not the OS-level fullscreen through
* F11/Alt+Enter (Windows, Linux) or buttons provided by the OS (macOS).
Expand Down
101 changes: 101 additions & 0 deletions src-main/windows/file-access-window.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const fsPromises = require('fs/promises');
const pathUtil = require('path');
const {getPlatform} = require('../platform');
const AbstractWindow = require('./abstract');
const {translate, getLocale, getStrings} = require('../l10n');
const {APP_NAME} = require('../brand');

/**
* @param {string} path
* @returns {Promise<boolean>} Promise that resolves to true if access seems to be missing.
*/
const missingFileAccess = async (path) => {
// Sanity check.
if (!pathUtil.isAbsolute(path)) {
return false;
}

try {
await fsPromises.stat(path);
} catch (e) {
if (e.code === 'ENOENT') {
return true;
}
}

// We were able to access the file, or the stat failed for a reason other than it not existing.
// Asking for more permission won't fix this.
return false;
};

class FileAccessWindow extends AbstractWindow {
constructor () {
super();

/** @type {string[]} */
this.paths = [];

/** @type {boolean} */
this.ready = false;

const ipc = this.window.webContents.ipc;

ipc.on('init', (e) => {
this.ready = true;

e.returnValue = {
locale: getLocale(),
strings: getStrings(),
APP_NAME,
initialPaths: this.paths,
};
});

this.window.setTitle(`${translate('file-access.window-title')} - ${APP_NAME}`);
this.window.setMinimizable(false);
this.window.setMaximizable(false);
this.loadURL('tw-file-access://./file-access.html');
}

getDimensions () {
return {
width: 600,
height: 300
};
}

getPreload () {
return 'file-access';
}

isPopup () {
return true;
}

/**
* @param {string} path
*/
addPath (path) {
if (!this.paths.includes(path)) {
this.paths.push(path);
if (this.ready) {
this.window.webContents.postMessage('new-path', path);
}
}
}

/**
* @param {string} path
*/
static async check (path) {
// This window only does anything in the Flatpak build for Linux
// https://github.com/electron/electron/issues/30650
if (getPlatform() === 'linux-flatpak' && await missingFileAccess(path)) {
const window = AbstractWindow.singleton(FileAccessWindow);
window.addPath(path);
window.show();
}
}
}

module.exports = FileAccessWindow;
5 changes: 5 additions & 0 deletions src-main/windows/packager.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const AbstractWindow = require('./abstract');
const {PACKAGER_NAME} = require('../brand');
const PackagerPreviewWindow = require('./packager-preview');
const prompts = require('../prompts');
const FileAccessWindow = require('./file-access-window');

class PackagerWindow extends AbstractWindow {
constructor (editorWindow) {
Expand Down Expand Up @@ -40,6 +41,10 @@ class PackagerWindow extends AbstractWindow {
event.returnValue = prompts.confirm(this.window, message);
});

ipc.handle('check-drag-and-drop-path', (event, path) => {
FileAccessWindow.check(path);
});

this.window.webContents.on('did-finish-load', () => {
// We can't do this from the preload script
this.window.webContents.executeJavaScript(`
Expand Down
17 changes: 17 additions & 0 deletions src-preload/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,20 @@ contextBridge.exposeInMainWorld('PromptsPreload', {
alert: (message) => ipcRenderer.sendSync('alert', message),
confirm: (message) => ipcRenderer.sendSync('confirm', message),
});

// In some Linux environments, people may try to drag & drop files that we don't have access to.
// Remove when https://github.com/electron/electron/issues/30650 is fixed.
if (navigator.userAgent.includes('Linux')) {
document.addEventListener('drop', (e) => {
if (e.isTrusted) {
for (const file of e.dataTransfer.files) {
// Using webUtils is safe as we don't have a legacy build for Linux
const {webUtils} = require('electron');
const path = webUtils.getPathForFile(file);
ipcRenderer.invoke('check-drag-and-drop-path', path);
}
}
}, {
capture: true
});
}
14 changes: 14 additions & 0 deletions src-preload/file-access.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const {ipcRenderer, contextBridge} = require('electron');

let newPathCallback = () => {};

contextBridge.exposeInMainWorld('FileAccessPreload', {
init: () => ipcRenderer.sendSync('init'),
onNewPath: (callback) => {
newPathCallback = callback;
}
});

ipcRenderer.on('new-path', (event, path) => {
newPathCallback(path);
});
17 changes: 17 additions & 0 deletions src-preload/packager.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,20 @@ if (ipcRenderer.sendSync('is-mas')) {
document.head.appendChild(style);
});
}

// In some Linux environments, people may try to drag & drop files that we don't have access to.
// Remove when https://github.com/electron/electron/issues/30650 is fixed.
if (navigator.userAgent.includes('Linux')) {
document.addEventListener('drop', (e) => {
if (e.isTrusted) {
for (const file of e.dataTransfer.files) {
// Using webUtils is safe as we don't have a legacy build for Linux
const {webUtils} = require('electron');
const path = webUtils.getPathForFile(file);
ipcRenderer.invoke('check-drag-and-drop-path', path);
}
}
}, {
capture: true
});
}
140 changes: 140 additions & 0 deletions src-renderer/file-access/file-access.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
<style>
body {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: black;
background-color: white;
accent-color: #ff4c4c;
}
main {
padding: 1rem;
box-sizing: border-box;
width: 100%;
min-height: 100vh;
border: 20px solid #ff4c4c;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
h1, p, ul {
margin: 0;
}
.file-path, .command {
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
}
.command::before {
content: '$ ';
}
</style>
</head>
<body>
<main>
<script>
// This file only does anything on Linux, so we don't need to worry about Windows paths.

const FLATPAK_APP_ID = 'org.turbowarp.TurboWarp';

// https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
const escapeForShellDoubleQuotes = (string) => string
.replace(/\\/g, '\\\\')
.replace(/\$/g, '\\$')
.replace(/`/g, '\\`')
.replace(/!/g, '\\!');

const makeNode = () => ({
leaf: false,
children: Object.create(null)
});
const root = makeNode();

const addPathToGraph = (path) => {
const parts = path.split('/');
let node = root;

// Paths always start with / and the last part is the filename, so ignore first and last item.
for (let i = 1; i < parts.length - 1; i++) {
const name = parts[i];
if (!Object.prototype.hasOwnProperty.call(node.children, name)) {
node.children[name] = makeNode();
}
node = node.children[name];
}

node.leaf = true;
};

const getLeafDirectories = () => {
const recurse = (path, node) => {
if (node.leaf) {
// Ignore children.
return [path];
}

const result = [];
for (const childName of Object.keys(node.children)) {
const childPath = `${path}${childName}/`;
const childLeaves = recurse(childPath, node.children[childName]);
for (const leaf of childLeaves) {
result.push(leaf);
}
}
return result;
};

return recurse('/', root);
};

const addPath = (path) => {
const pathElement = document.createElement('li');
pathElement.className = 'file-path';
pathElement.textContent = path;
fileListElement.appendChild(pathElement);

addPathToGraph(path);
const overrides = getLeafDirectories().map(i => {
// --filesystem=/ isn't valid, need to use --filesystem=host
const value = i === '/' ? 'host' : i;
return `--filesystem="${escapeForShellDoubleQuotes(value)}"`;
});
const command = `flatpak override ${FLATPAK_APP_ID} --user ${overrides.join(' ')}`;
commandElement.textContent = command;
};

FileAccessPreload.onNewPath(addPath);

const {locale, strings, APP_NAME, initialPaths} = FileAccessPreload.init();
document.documentElement.lang = locale;
</script>

<p class="introduction"></p>
<script>
document.querySelector('.introduction').textContent = strings['file-access.flatpak'].replace('{APP_NAME}', APP_NAME);
</script>

<ul class="file-list"></ul>

<p class="how-to-fix"></p>
<script>
document.querySelector('.how-to-fix').textContent = strings['file-access.how-to-fix'];
</script>

<p class="command"></p>

<script>
const fileListElement = document.querySelector('.file-list');
const commandElement = document.querySelector('.command');
for (const path of initialPaths) {
addPath(path);
}
</script>
</main>
</body>
</html>

0 comments on commit 79e8db5

Please sign in to comment.