6. 一応完成
好きな行の後ろに空行を追加できるようにした
import subprocess
import sys
import os
import json
import sqlite3
import re
import customtkinter as ct
from datetime import datetime
from tkinter import messagebox
from PIL import Image #from tkinter import PhotoImage PhotoImageだと拡大縮小ができない
from customtkinter import CTkImage
CONFIG_FILE = "usb_auth.json"
DB_NAME = "passwords.db"
# ==========
# 挿入されているすべてのUSB情報を取得
# ==========
def get_usb_list():
usb_dict = {}
try:
# PCに認識されている倫理ディスク(logicaldisk)を取得する
# logicaldiskにはドライブレター(C:,D:,E:等)とその説明(LocalFixedDisk,RemovalDisk等)が書かれている
# ここで「RemovalDisk」となっているものがUSBメモリやSDカードを意味する
drives = subprocess.check_output(
"wmic logicaldisk get name,description",
shell=True
).decode("utf-8", errors="ignore").splitlines()
# driveからRemovableを含む行(line)を探し、line.split()[0](最初のトークン、通常はE:のようなもの)を抜き出す
removable_drives = []
for line in drives:
line = line.strip()
if "Removable" in line:
m = re.search(r"([A-Z]:)", line)
if m:
removable_drives.append(m.group(1))
# 初期値として「不明なUSB」をセット
for drive in removable_drives:
usb_dict[drive] = ("不明なUSB", "UNKNOWN")
# 実際にPCに接続されているハードウェア情報(diskdrive)を取得する
# 物理ディスク(接続ディスク)の製品名(Caption)とシリアル番号を取得
result = subprocess.check_output(
"wmic diskdrive get Caption,DeviceID,SerialNumber",
shell=True
).decode("utf-8", errors="ignore").splitlines()
# resultからUSBを含む行(line)を探し、line.split()して分解
for line in result:
if "USB" in line:
parts = line.split()
if len(parts) >= 3:
caption = " ".join(parts[:-2]) #製品名
device_id = parts[-2] #DeviceID
serial = parts[-1] #シリアル番号
for d in usb_dict:
if usb_dict[d][1] == "UNKNOWN":
usb_dict[d] = (caption, serial)
break
except Exception as e:
print("USB取得エラー:", e)
return usb_dict
# ==========
# 指定したserial(USBのシリアル番号)とドライブレターをJSONファイル(CONFIGファイル)に上書き保存する
# ==========
def save_usb(serial, drive):
with open(CONFIG_FILE, "w") as f:
json.dump({"serial": serial, "drive": drive}, f)
# ==========
# CONFIGファイルをロードして登録したUSBのシリアル番号を取得
# ==========
def load_usb():
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
return json.load(f) # dictを返す(configファイルがなければFalseを返す)
# ==========
# 挿したUSBのシリアル番号が登録したものと合致するか確認する
# ==========
def check_usb():
# configは{"serial": "xxx", "drive": "E:"}のようなdictになる
config = load_usb()
# CONFIGファイルが空ならFalse
if not config:
return False
# dictからシリアル番号を取り出す
saved_serial = config.get("serial")
usb_list = get_usb_list()
return saved_serial in [serial for _, serial in usb_list.values()]
# ==========
# USBを登録していない場合は、選択画面を表示する
# ==========
def select_usb():
usb_list = get_usb_list()
if not usb_list:
messagebox.showerror("エラー", "USBが見つかりません")
sys.exit()
win = ct.CTk()
win.title("USB選択")
ct.CTkLabel(win, text="起動に使うUSBを選択してください").pack(pady=10)
#ボタンを押すとメッセージボックスを表示
def on_select(serial, drive):
save_usb(serial, drive)
messagebox.showinfo("登録完了", f"USB({serial})が登録されました。もう一度アプリを起動させてください。")
win.destroy()
#Removable - BUFFALO USB - 0F7315812030 のようなボタンが表示される
for drive, (caption, serial) in usb_list.items():
text = f"{drive} - {caption} - {serial}"
ct.CTkButton(win, text=text, command=lambda s=serial, d=drive: on_select(s, d)).pack(pady=5)
win.mainloop()
# ==========
# DB初期化
# ==========
def init_db(db_path):
conn = sqlite3.connect(db_path)
c = conn.cursor()
# アカウントデータのテーブル
c.execute("""CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT,
username TEXT,
password TEXT,
created_at TEXT,
sort_order INTEGER,
category TEXT
)""")
# カテゴリ専用テーブル
c.execute("""CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE
)""")
conn.commit()
return conn
# ==========
# タブ切り替え
# ==========
def show_tab(tab_name):
global tab_frames, current_tab
for name, frame in tab_frames.items():
frame.pack_forget()
tab_frames[tab_name].pack(fill="y", expand=True, pady=5)
current_tab = tab_name
# ==========
# カテゴリーをDBから読み込む
# ==========
def load_categories(conn):
c = conn.cursor()
c.execute("SELECT name FROM categories ORDER BY id ASC")
rows = c.fetchall()
if rows:
return [r[0] for r in rows]
else:
c.execute("INSERT INTO categories (name) VALUES (?)", ("タブ1",))
conn.commit()
return ["タブ1"]
# ==========
# GUI
# ==========
def run_app(conn):
global service_entry, user_entry, pass_entry #, category_menu
global tab_frames, current_tab, list_frames, root, categories
root = ct.CTk()
root.title("パスワード管理アプリwithUSB")
root.geometry("1100x700")
# ==========
# GUIを左右2分割
# ==========
main_frame = ct.CTkFrame(root)
main_frame.pack(fill="both", expand=True, padx=10, pady=0)
bg_color = main_frame._apply_appearance_mode(main_frame._fg_color)
left_frame = ct.CTkFrame(main_frame, width=200)
left_frame.pack(side="left", fill="y", expand=True, padx=(0,10), pady=0)
right_frame = ct.CTkFrame(main_frame, fg_color=bg_color)
right_frame.pack(side="right", fill="both", expand=True)
# ==========
# タブボタンフレーム(画面左側)
# ==========
bg_color = left_frame._apply_appearance_mode(left_frame._fg_color)
tab_buttons_frame = ct.CTkFrame(left_frame, fg_color=bg_color)
tab_buttons_frame.pack(fill="y", expand=True, pady=5)
# ==========
# 検索ボタン(画面右側一番上)
# ==========
bg_color = right_frame._apply_appearance_mode(right_frame._fg_color)
search_frame = ct.CTkFrame(right_frame, fg_color=bg_color)
search_frame.pack(padx=10, pady=5, fill="x")
pil_image = Image.open("icon-search.png")
search_icon = CTkImage(light_image=pil_image, dark_image=pil_image, size=(24,24))
search_btn = ct.CTkButton(search_frame, image=search_icon, text="", width=16, height=16)
search_btn.pack(side="right", padx=5)
search_var = ct.StringVar()
search_entry = ct.CTkEntry(search_frame, textvariable=search_var, width=250)
search_entry.pack(side="right", padx=10, pady=5, fill="x")
search_var.trace_add("write", lambda *args: search_account_list())
# ==========
# 検索結果用フレーム(最初は隠しておく)
# ==========
search_result_frame = ct.CTkFrame(right_frame)
search_result_frame.pack(fill="both", expand=True)
search_result_frame.pack_forget()
# ==========
# 検索機能
# ==========
def search_account_list():
# フレームを一旦削除
for widget in search_result_frame.winfo_children():
widget.destroy()
# 検索キーワード取得
keyword = search_var.get().lower().strip()
# キーワードが空ならリセットする
if keyword == "":
reset_account_list()
return
# サービス名orユーザー名にキーワードが入っているアカウントを取得
c = conn.cursor()
c.execute("SELECT id, service, username, password FROM accounts")
accounts = c.fetchall()
if keyword:
accounts = [
acc for acc in accounts
if keyword in acc[1].lower() or keyword in acc[2].lower()
]
# 該当アカウント一覧を表示
if accounts:
for acc in accounts:
service, username, password = acc[1], acc[2], acc[3]
label = ct.CTkLabel(search_result_frame, text=f"{service} | {username} | {password}")
label.pack(anchor="w", padx=10, pady=2)
else:
ct.CTkLabel(search_result_frame, text="該当なし").pack(anchor="w", padx=10, pady=2)
# 検索時はlist_framesとcontent_frameを隠してsearch_result_frameを表示
for frame in list_frames.values():
frame.pack_forget()
content_frame.pack_forget()
search_result_frame.pack(fill="both", expand=True)
# ==========
# 検索解除機能
# ==========
def reset_account_list():
search_result_frame.pack_forget()
for frame in list_frames.values():
frame.pack(fill="both", expand=True)
content_frame.pack(fill="both", expand=True)
# カテゴリ選択
categories = load_categories(conn)
# ==========
# タブフレーム(画面右側)
# ===========
tab_frames = {}
current_tab = None
content_frame = ct.CTkFrame(right_frame, fg_color=bg_color)
content_frame.pack(fill="both", expand=True)
# ==========
# タブボタンフレーム(画面左側)再描画関数
# ==========
def redraw_tab_buttons():
for widget in tab_buttons_frame.winfo_children():
widget.destroy()
for cat in categories:
tab_item_frame = ct.CTkFrame(tab_buttons_frame, fg_color="transparent")
tab_item_frame.pack(fill="x")
btn = ct.CTkButton(
tab_item_frame,
text=cat,
corner_radius=0,
fg_color="transparent",
hover_color="#444444",
command=lambda t=cat: show_tab(t)
)
btn.pack(fill="x", pady=5)
line = ct.CTkFrame(tab_item_frame, height=2, fg_color="#444444")
line.pack(fill="x", side="bottom")
# タブ追加ボタン
add_tab_btn = ct.CTkButton(tab_buttons_frame, text="+ タブを追加", command=add_tab)
add_tab_btn.pack(padx=10, pady=10)
# ==========
# タブ作成関数(Tabviewだとタブ名の変更ができない)
# ==========
def create_tab(tab_name):
frame = ct.CTkFrame(content_frame, fg_color=bg_color)
tab_frames[tab_name] = frame
# ==========
# tab_top_frame(タブタイトルとタブ名変更ボタンを配置)
# ==========
tab_top_frame = ct.CTkFrame(frame)
tab_top_frame.pack(fill="x", pady=5)
title_label = ct.CTkLabel(tab_top_frame, text=tab_name, font=ct.CTkFont(size=16, weight="bold"))
title_label.pack(side="left", padx=5)
# ==========
# タブ名変更関数
# ==========
def rename_tab_local(old_name=tab_name, label=title_label):
old_name = label.cget("text")
new_name = ct.CTkInputDialog(text="新しいタブ名を入力").get_input()
if new_name:
# categoriesリストの更新
idx = categories.index(old_name)
categories[idx] = new_name
# 辞書も更新
tab_frames[new_name] = tab_frames.pop(old_name)
# ラベル更新
label.configure(text=new_name)
# DBのcategoriesテーブルを更新
c= conn.cursor()
c.execute("UPDATE categories SET name=? WHERE name=?", (new_name, old_name))
# DBのaccountsテーブルのcategoryカラムも更新
c.execute("UPDATE accounts SET category=? WHERE category=?", (new_name, old_name))
conn.commit()
# タブボタンフレーム(画面左側)を再描画
redraw_tab_buttons()
# ==========
# タブ削除関数
# ==========
def delete_tab_local(tab_to_delete=tab_name):
if messagebox.askyesno("確認", f"タブ「{tab_to_delete}」を削除しますか?\n※このタブにあるデータも削除されます"):
# DBのaccountsテーブルから該当タブのアカウントを削除
c = conn.cursor()
c.execute("DELETE FROM accounts WHERE category=?", (tab_to_delete,))
# DBのcategoriesテーブルから削除
c.execute("DELETE FROM categories WHERE name=?", (tab_to_delete,))
conn.commit()
# categoriesリストから削除
categories.remove(tab_to_delete)
# 削除したタブのフレームを破棄して画面に残らないようにする(辞書から取り出して破壊)
frame_to_delete = tab_frames.pop(tab_to_delete, None)
if frame_to_delete:
frame_to_delete.destroy()
# list_framesからも削除して破壊
if tab_to_delete in list_frames:
list_frames[tab_to_delete].destroy()
list_frames.pop(tab_to_delete)
# タブボタンフレーム(画面左側)を再描画
redraw_tab_buttons()
# タブ名変更ボタン
rename_btn = ct.CTkButton(tab_top_frame, text="名前変更", width=60, command=lambda l=title_label: rename_tab_local(l))
rename_btn.pack(side="left", padx=5)
# タブ削除ボタン
delete_btn = ct.CTkButton(tab_top_frame, text="削除", width=60, command=delete_tab_local)
delete_btn.pack(side="left", padx=5)
# スクロールフレーム
list_frames[tab_name] = ct.CTkScrollableFrame(frame, width=900, height=400)
list_frames[tab_name].pack(fill="both", expand=True)
# ==========
# 入力用フレーム(画面右側一番下)
# ==========
input_frame = ct.CTkFrame(frame)
input_frame.pack(side="bottom", pady=10, fill="x")
service_entry = ct.CTkEntry(input_frame, placeholder_text="サービス名", width=150)
service_entry.grid(row=0, column=0, padx=5, pady=5)
user_entry = ct.CTkEntry(input_frame, placeholder_text="ユーザー名", width=150)
user_entry.grid(row=0, column=1, padx=5, pady=5)
pass_entry = ct.CTkEntry(input_frame, placeholder_text="パスワード", width=150)
pass_entry.grid(row=0, column=2, padx=5, pady=5)
add_btn = ct.CTkButton(
input_frame, text="追加",
command=lambda s=service_entry, u=user_entry, p=pass_entry, c=tab_name: add_account(conn, s, u, p, c))
add_btn.grid(row=0, column=3, padx=5, pady=5)
# タブごとに一覧表示(Treeviewだとセル単位で編集できない、デザインがダサいので使わない)
list_frames = {}
for cat in categories:
create_tab(cat)
# ==========
# タブボタンフレーム(画面左側)にタブボタンを縦方向に配置
# ==========
for cat in categories:
tab_item_frame = ct.CTkFrame(tab_buttons_frame, fg_color="transparent")
tab_item_frame.pack(fill="x")
btn = ct.CTkButton(
tab_item_frame,
text=cat,
corner_radius=0,
fg_color="transparent",
hover_color="#444444",
command=lambda t=cat: show_tab(t)
)
btn.pack(fill="x", pady=5)
line = ct.CTkFrame(tab_item_frame, height=2, fg_color="#444444") # height=1だと見えない
line.pack(fill="x", side="bottom")
# ==========
# タブ追加機能
# ==========
def add_tab():
global categories
new_name = ct.CTkInputDialog(text="追加したいタブ名を入力してください").get_input()
if new_name and new_name not in categories:
categories.append(new_name)
# DBに保存(既にある場合はスキップPASS)
c = conn.cursor()
try:
c.execute("INSERT INTO categories (name) VALUES (?)", (new_name,))
conn.commit()
except sqlite3.IntegrityError:
pass
create_tab(new_name)
# 画面左のタブボタンを再描画
redraw_tab_buttons()
# 新しく作ったタブを表示
show_tab(new_name)
# タブ追加ボタンをtab_buttons_frame内に配置
add_tab_btn = ct.CTkButton(tab_buttons_frame, text="+ タブを追加", command=add_tab)
add_tab_btn.pack(padx=10, pady=10)
# 最初のタブを表示する
show_tab(categories[0])
# USB変更ボタン
ct.CTkButton(root, text="USBを更新する", command=change_usb).pack(pady=10)
# 最初の表示
show_account(conn)
root.mainloop()
# ==========
# アカウントをデータベースに追加
# ==========
def add_account(conn, service_entry, user_entry, pass_entry, category):
service = service_entry.get()
user = user_entry.get()
password = pass_entry.get()
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
if not service or not user or not password:
messagebox.showerror("エラー", "すべて入力してください")
return
# sort_orderの最大値を探して+1する
c = conn.cursor()
c.execute("SELECT MAX(sort_order) FROM accounts WHERE category=?", (category,))
max_order = c.fetchone()[0]
if max_order is None:
max_order = 0
sort_order = max_order + 1
c.execute("INSERT INTO accounts (service, username, password, created_at, sort_order, category) VALUES (?, ?, ?, ?, ?, ?)",
(service, user, password, now, sort_order, category))
conn.commit()
messagebox.showinfo("保存", f"{category}にデータを保存しました")
# 表示更新
show_account(conn)
# ==========
# 空のアカウントをデータベースに追加
# ==========
def insert_empty_account(conn, account_id, category):
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
c = conn.cursor()
c.execute("SELECT sort_order FROM accounts WHERE id=?", (account_id,))
current_order = c.fetchone()[0]
# 後ろの行のsort_orderを+1ずつずらしてスペースをつくる
c.execute("UPDATE accounts SET sort_order = sort_order + 1 WHERE category=? AND sort_order > ?", (category, current_order))
# 空行を挿入
c.execute("INSERT INTO accounts (service, username, password, created_at, sort_order, category) VALUES (?, ?, ?, ?, ?, ?)",
("", "", "", now, current_order + 1, category))
conn.commit()
# 表示更新
show_account(conn)
# ==========
# アカウントの表示(カテゴリごと)
# ==========
def show_account(conn):
# 既存のフレームを削除
for frame in list_frames.values():
for widget in frame.winfo_children():
widget.destroy()
c = conn.cursor()
c.execute("SELECT id, service, username, password, created_at, sort_order, category FROM accounts ORDER BY sort_order ASC")
rows = c.fetchall()
# ヘッダー(サービス名、ユーザー名、パスワード等)
for category, frame in list_frames.items():
headers = ["サービス名", "ユーザー名", "パスワード", "更新日時", "更新", "削除", "上", "下", "追加"]
for col, text in enumerate(headers):
label = ct.CTkLabel(frame, text=text, font=ct.CTkFont(weight="bold"))
label.grid(row=0, column=col, padx=5, pady=2)
#
for row in rows:
account_id, service, username, password, created_at, sort_order, category = row
frame = list_frames.get(category)
if not frame:
continue
# row=0はヘッダーなので1から開始する(1行の要素数8に応じて計算)
idx = 1 + len(frame.winfo_children()) // 9
service_entry = ct.CTkEntry(frame, width=150)
service_entry.insert(0, service)
service_entry.grid(row=idx, column=0, padx=5, pady=2)
user_entry = ct.CTkEntry(frame, width=150)
user_entry.insert(0, username)
user_entry.grid(row=idx, column=1, padx=5, pady=2)
pass_entry = ct.CTkEntry(frame, width=100)
pass_entry.insert(0, password)
pass_entry.grid(row=idx, column=2, padx=5, pady=2)
created_entry = ct.CTkEntry(frame, width=150)
created_entry.insert(0, created_at or "")
created_entry.grid(row=idx, column=3, padx=5, pady=2)
# 更新ボタン
update_btn = ct.CTkButton(
frame, text = "更新",
command=lambda i=account_id, s=service_entry, u=user_entry, p=pass_entry: update_account(conn, i, s, u, p),
width=60
)
update_btn.grid(row=idx, column=4, padx=5)
# 削除ボタン
delete_btn = ct.CTkButton(
frame, text = "削除",
command=lambda i=account_id: delete_account(conn, i),
width=60
)
delete_btn.grid(row=idx, column=5, padx=5)
# 行入れ替えボタン
upper_btn = ct.CTkButton(frame, text="↑", command=lambda i=account_id: move_account(conn, i, -1), width=30)
upper_btn.grid(row=idx, column=6, padx=2)
lower_btn = ct.CTkButton(frame, text="↓", command=lambda i=account_id: move_account(conn, i, +1), width=30)
lower_btn.grid(row=idx, column=7, padx=2)
# 好きな行の後ろに空行を追加するボタン
insert_btn = ct.CTkButton(frame, text="+", width=30, command=lambda i=account_id, c=category, conn=conn: insert_empty_account(conn, i, c))
insert_btn.grid(row=idx, column=8, padx=2)
# ==========
# アカウント更新
# ==========
def update_account(conn, account_id, service_entry, user_entry, pass_entry):
service = service_entry.get()
user = user_entry.get()
password = pass_entry.get()
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
c = conn.cursor()
c.execute("UPDATE accounts SET service=?, username=?, password=?, created_at=? WHERE id=?",
(service, user, password, now, account_id))
conn.commit()
messagebox.showinfo("更新", "データを更新しました")
# 表示更新する
show_account(conn)
# ==========
# アカウント削除
# ==========
def delete_account(conn, account_id):
if messagebox.askyesno("確認", "本当に削除しますか?"):
c = conn.cursor()
c.execute("DELETE FROM accounts WHERE id=?", (account_id,))
conn.commit()
show_account(conn)
# ==========
# アカウントの行入れ替え
# ==========
def move_account(conn, account_id, direction):
# 現在のsort_orderの数値を取得
c = conn.cursor()
c.execute("SELECT sort_order FROM accounts WHERE id=?", (account_id,))
current_order = c.fetchone()[0]
# 隣接している行を取得
if direction == -1:
c.execute("SELECT id, sort_order FROM accounts WHERE sort_order < ? ORDER BY sort_order DESC LIMIT 1", (current_order,))
else:
c.execute("SELECT id, sort_order FROM accounts WHERE sort_order > ? ORDER BY sort_order ASC LIMIT 1", (current_order,))
neighbor = c.fetchone()
# 隣接行と入れ替える
if neighbor:
neighbor_id, neighbor_order = neighbor
c.execute("UPDATE accounts SET sort_order=? WHERE id=?", (neighbor_order, account_id))
c.execute("UPDATE accounts SET sort_order=? WHERE id=?", (current_order, neighbor_id))
conn.commit()
show_account(conn)
# ==========
# タブ名変更
# ==========
# ==========
# USBの変更
# ==========
def change_usb():
select_usb()
messagebox.showinfo("USB変更", "USBが変更されました。次回起動から有効です。")
# ==========
# アプリを起動したときの処理
# ==========
# configファイルからdictを取得
config = load_usb()
if not config:
# CONFIGファイルがない初回起動ではUSB選択画面を表示する
select_usb()
# 一度システム終了する
sys.exit()
# 登録USBを挿入していなければエラーを表示する
if not check_usb():
messagebox.showerror("エラー", "認証USBが見つかりません。アプリを終了します。")
sys.exit()
# CONFIGファイルがある場合は、dictからドライブレターを取得
drive = config.get("drive")
# 念の為、PATHが存在するか確認
#usb_path = drive + os.sep
#if not os.path.exists(usb_path):
# messagebox.showerror("エラー", f"USBドライブ{drive}が見つかりません")
# sys.exit()
# DBファイルの絶対パスを作成(os.sepは"/"と同じ意味)
db_path = os.path.join(drive + os.sep, DB_NAME)
# USB内のDBを初期化する
conn = init_db(db_path)
#画面表示
ct.set_appearance_mode("dark")
ct.set_default_color_theme("blue")
run_app(conn)