Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/UI.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ Sprint 1 시연에서 가장 중요한 기준은 `도면 불러오기 -> 검토/
- 앱 데이터 경로에 최근 프로젝트 인덱스 저장
- 다음 앱 실행 시 Project Navigator 목록에 표시

저장 위치 정책:

- 기본 위치는 `<사용자 Documents>/SafeCrowd Projects/<프로젝트 이름>`이며, 새 프로젝트 화면에서 프로젝트 이름을 입력하면 폴더 경로가 이 규칙으로 자동 제안된다.
- 자동 제안 경로가 이미 비어 있지 않은 폴더와 충돌하면 ` (2)`, ` (3)` 같은 접미를 붙여 사용 가능한 경로를 찾는다.
- 사용자가 Browse로 직접 고른 경로는 사용자의 의사로 보고, 이후 프로젝트 이름을 바꿔도 자동 갱신하지 않는다.
- 폴더명은 OS 금지 문자(`\\ / : * ? " < > |`)를 `_`로 치환해 정규화한다.
- 저장 시점에 다음 조건을 만족하지 않으면 거부한다.
- 드라이브 또는 볼륨의 루트가 아니어야 한다.
- 심볼릭 링크 또는 Windows 정션이 아니어야 한다.
- 비어 있거나, SafeCrowd 관리 파일(`safecrowd-project.json`, `layout.dxf`, `layout-review.json`, `workspace-state.json`)만 존재해야 한다.
- 최근 프로젝트 인덱스는 `QStandardPaths::AppDataLocation` 아래에 저장한다. `QApplication`의 organization name과 application name을 모두 `SafeCrowd`로 고정하므로, Windows 기준 경로는 `%APPDATA%/SafeCrowd/SafeCrowd/recent-projects.json`이 된다.

## 3. 화면별 기능 기준

### 3.1 Project Navigator
Expand Down
83 changes: 82 additions & 1 deletion src/application/NewProjectWidget.cpp
Original file line number Diff line number Diff line change
@@ -1,19 +1,77 @@
#include "application/NewProjectWidget.h"

#include <QDir>
#include <QFileDialog>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QRegularExpression>
#include <QSignalBlocker>
#include <QSizePolicy>
#include <QStandardPaths>
#include <QVBoxLayout>

#include "application/UiStyle.h"

namespace safecrowd::application {
namespace {

constexpr auto kProjectsRootName = "SafeCrowd Projects";

// String-only; saveProject creates the folder lazily on actual save.
QString defaultProjectsRoot() {
auto base = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
if (base.isEmpty()) {
base = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
}
if (base.isEmpty()) {
return QString();
}
return QDir(base).filePath(kProjectsRootName);
}

QString sanitizeFolderName(const QString& name) {
static const QRegularExpression invalid(R"([\\/:*?"<>|])");
auto cleaned = name.trimmed();
cleaned.replace(invalid, "_");
cleaned = cleaned.simplified();
return cleaned;
}

bool folderIsAvailableForSuggestion(const QString& path) {
QDir dir(path);
if (!dir.exists()) {
return true;
}
const auto entries = dir.entryInfoList(
QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot);
return entries.isEmpty();
}

QString suggestProjectFolder(const QString& projectName) {
const auto root = defaultProjectsRoot();
if (root.isEmpty()) {
return QString();
}
const auto sanitized = sanitizeFolderName(projectName);
if (sanitized.isEmpty()) {
return root;
}
const auto base = QDir(root).filePath(sanitized);
if (folderIsAvailableForSuggestion(base)) {
return base;
}
for (int suffix = 2; suffix < 1000; ++suffix) {
const auto candidate = QDir(root).filePath(QStringLiteral("%1 (%2)").arg(sanitized).arg(suffix));
if (folderIsAvailableForSuggestion(candidate)) {
return candidate;
}
}
return base; // fall back; saveProject will surface a clear error
}

QPushButton* createOutlinedButton(const QString& text, QWidget* parent) {
auto* button = new QPushButton(text, parent);
button->setFont(ui::font(ui::FontRole::Body));
Expand Down Expand Up @@ -124,9 +182,32 @@ NewProjectWidget::NewProjectWidget(QWidget* parent)
}
});

connect(projectNameEdit_, &QLineEdit::textChanged, this, [this](const QString& name) {
if (folderEditedByUser_) {
return;
}
const auto suggestion = suggestProjectFolder(name);
const QSignalBlocker blocker(folderPathEdit_);
folderPathEdit_->setText(suggestion);
});

connect(folderBrowseButton, &QPushButton::clicked, this, [this]() {
const auto path = QFileDialog::getExistingDirectory(this, "Select Project Folder");
QString startDir = folderPathEdit_->text().trimmed();
if (startDir.isEmpty()) {
startDir = defaultProjectsRoot();
}
if (!startDir.isEmpty()) {
QDir candidate(startDir);
while (!candidate.exists() && !candidate.isRoot() && candidate.cdUp()) {
}
if (candidate.exists()) {
startDir = candidate.absolutePath();
}
}

const auto path = QFileDialog::getExistingDirectory(this, "Select Project Folder", startDir);
if (!path.isEmpty()) {
folderEditedByUser_ = true;
folderPathEdit_->setText(path);
}
});
Expand Down
1 change: 1 addition & 0 deletions src/application/NewProjectWidget.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class NewProjectWidget : public QWidget {
QLineEdit* projectNameEdit_{nullptr};
QLineEdit* layoutPathEdit_{nullptr};
QLineEdit* folderPathEdit_{nullptr};
bool folderEditedByUser_{false};
std::function<void(const NewProjectRequest&)> doneHandler_{};
std::function<void()> cancelHandler_{};
};
Expand Down
81 changes: 81 additions & 0 deletions src/application/ProjectPersistence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,83 @@ bool canDeleteProjectFolder(const QString& folderPath, QString* errorMessage) {
return true;
}

// Verifies the *location* of a folder being used to save a SafeCrowd project.
// Rejects symlinks/junctions and drive/volume roots. Used by the save path;
// the delete path keeps its own checks for now.
bool validateProjectFolderLocation(const QString& folderPath, QString* errorMessage) {
const auto setError = [errorMessage](const QString& message) {
if (errorMessage != nullptr) {
*errorMessage = message;
}
};

if (folderPath.isEmpty()) {
setError("Project folder path is empty.");
return false;
}

const QFileInfo folderInfo(QDir(folderPath).absolutePath());
if (folderInfo.isSymLink() || folderInfo.isJunction()) {
setError(QString("Refusing to save into a symbolic link or junction: %1").arg(folderPath));
return false;
}

if (QDir(folderInfo.absoluteFilePath()).isRoot()) {
setError(QString("Refusing to save into a drive root: %1").arg(folderPath));
return false;
}

const QStorageInfo storage(folderInfo.absoluteFilePath());
if (storage.isValid() && !storage.rootPath().isEmpty()
&& QFileInfo(storage.rootPath()).absoluteFilePath() == folderInfo.absoluteFilePath()) {
setError(QString("Refusing to save into a volume root: %1").arg(folderPath));
return false;
}

return true;
}

// Verifies that `folderPath` is safe to receive a SafeCrowd project save.
// The folder may not yet exist (it will be created); if it exists, it must
// be either empty or contain only SafeCrowd-managed files.
bool canSaveIntoProjectFolder(const QString& folderPath, QString* errorMessage) {
const auto setError = [errorMessage](const QString& message) {
if (errorMessage != nullptr) {
*errorMessage = message;
}
};

if (!validateProjectFolderLocation(folderPath, errorMessage)) {
return false;
}

QDir folder(folderPath);
if (!folder.exists()) {
return true;
}

const auto entries = folder.entryInfoList(
QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot,
QDir::Name);
for (const auto& entry : entries) {
if (entry.isSymLink() || entry.isJunction()) {
setError(QString(
"Refusing to save into a folder that contains a link not created by SafeCrowd: %1")
.arg(entry.fileName()));
return false;
}
if (!entry.isFile() || !isProjectManagedEntry(entry.fileName())) {
setError(QString(
"Refusing to save into a folder that contains a file or folder not created by SafeCrowd: %1\n\n"
"Please choose an empty folder, or a folder that already contains a SafeCrowd project.")
.arg(entry.fileName()));
return false;
}
}

return true;
}

bool copyLayoutIntoProject(ProjectMetadata& metadata, QString* errorMessage) {
const auto sourcePath = QFileInfo(metadata.layoutPath).absoluteFilePath();
const auto targetPath = QDir(metadata.folderPath).filePath(kLayoutFileName);
Expand Down Expand Up @@ -1250,6 +1327,10 @@ bool ProjectPersistence::saveProject(ProjectMetadata metadata, QString* errorMes
return false;
}

if (!canSaveIntoProjectFolder(metadata.folderPath, errorMessage)) {
return false;
}

QDir folder(metadata.folderPath);
if (!folder.exists() && !QDir().mkpath(metadata.folderPath)) {
if (errorMessage != nullptr) {
Expand Down
4 changes: 4 additions & 0 deletions src/application/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
#include "engine/EngineRuntime.h"

int main(int argc, char* argv[]) {
QApplication::setOrganizationName("SafeCrowd");
QApplication::setOrganizationDomain("safecrowd.local");
QApplication::setApplicationName("SafeCrowd");

QApplication app(argc, argv);
app.setStyleSheet(safecrowd::application::ui::appStyleSheet());

Expand Down
Loading